/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */

import {isNode} from '@angular/private/testing';

import {
  animate,
  animation,
  AnimationMetadata,
  AnimationMetadataType,
  AnimationOptions,
  AUTO_STYLE,
  group,
  keyframes,
  query,
  sequence,
  state,
  style,
  transition,
  trigger,
  useAnimation,
  ɵStyleDataMap,
} from '../../../src/animations';

import {Animation} from '../../src/dsl/animation';
import {buildAnimationAst} from '../../src/dsl/animation_ast_builder';
import {AnimationTimelineInstruction} from '../../src/dsl/animation_timeline_instruction';
import {ElementInstructionMap} from '../../src/dsl/element_instruction_map';
import {MockAnimationDriver} from '../../testing';

function createDiv() {
  return document.createElement('div');
}

describe('Animation', () => {
  // these tests are only meant to be run within the DOM (for now)
  if (isNode) {
    // Jasmine will throw if there are no tests.
    it('should pass', () => {});
    return;
  }

  let rootElement: any;
  let subElement1: any;
  let subElement2: any;

  beforeEach(() => {
    rootElement = createDiv();
    subElement1 = createDiv();
    subElement2 = createDiv();
    document.body.appendChild(rootElement);
    rootElement.appendChild(subElement1);
    rootElement.appendChild(subElement2);
  });

  afterEach(() => {
    rootElement.remove();
  });

  describe('validation', () => {
    it('should throw an error if one or more but not all keyframes() styles contain offsets', () => {
      const steps = animate(1000, keyframes([style({opacity: 0}), style({opacity: 1, offset: 1})]));

      expect(() => {
        validateAndThrowAnimationSequence(steps);
      }).toThrowError(/Not all style\(\) steps within the declared keyframes\(\) contain offsets/);
    });

    it('should throw an error if not all offsets are between 0 and 1', () => {
      let steps = animate(
        1000,
        keyframes([style({opacity: 0, offset: -1}), style({opacity: 1, offset: 1})]),
      );

      expect(() => {
        validateAndThrowAnimationSequence(steps);
      }).toThrowError(/Please ensure that all keyframe offsets are between 0 and 1/);

      steps = animate(
        1000,
        keyframes([style({opacity: 0, offset: 0}), style({opacity: 1, offset: 1.1})]),
      );

      expect(() => {
        validateAndThrowAnimationSequence(steps);
      }).toThrowError(/Please ensure that all keyframe offsets are between 0 and 1/);
    });

    it('should throw an error if a smaller offset shows up after a bigger one', () => {
      let steps = animate(
        1000,
        keyframes([style({opacity: 0, offset: 1}), style({opacity: 1, offset: 0})]),
      );

      expect(() => {
        validateAndThrowAnimationSequence(steps);
      }).toThrowError(/Please ensure that all keyframe offsets are in order/);
    });

    it('should throw an error if any styles overlap during parallel animations', () => {
      const steps = group([
        sequence([
          // 0 -> 2000ms
          style({opacity: 0}),
          animate('500ms', style({opacity: 0.25})),
          animate('500ms', style({opacity: 0.5})),
          animate('500ms', style({opacity: 0.75})),
          animate('500ms', style({opacity: 1})),
        ]),
        animate(
          '1s 500ms',
          keyframes([
            // 0 -> 1500ms
            style({width: 0}),
            style({opacity: 1, width: 1000}),
          ]),
        ),
      ]);

      expect(() => {
        validateAndThrowAnimationSequence(steps);
      }).toThrowError(
        /The CSS property "opacity" that exists between the times of "0ms" and "2000ms" is also being animated in a parallel animation between the times of "0ms" and "1500ms"/,
      );
    });

    it('should not throw an error if animations overlap in different query levels within different transitions', () => {
      const steps = trigger('myAnimation', [
        transition(
          'a => b',
          group([
            query('h1', animate('1s', style({opacity: 0}))),
            query('h2', animate('1s', style({opacity: 1}))),
          ]),
        ),

        transition(
          'b => a',
          group([
            query('h1', animate('1s', style({opacity: 0}))),
            query('h2', animate('1s', style({opacity: 1}))),
          ]),
        ),
      ]);

      expect(() => validateAndThrowAnimationSequence(steps)).not.toThrow();
    });

    it('should not allow triggers to be defined with a prefixed `@` symbol', () => {
      const steps = trigger('@foo', []);

      expect(() => validateAndThrowAnimationSequence(steps)).toThrowError(
        /animation triggers cannot be prefixed with an `@` sign \(e\.g\. trigger\('@foo', \[...\]\)\)/,
      );
    });

    it('should throw an error if an animation time is invalid', () => {
      const steps = [animate('500xs', style({opacity: 1}))];

      expect(() => {
        validateAndThrowAnimationSequence(steps);
      }).toThrowError(/The provided timing value "500xs" is invalid/);

      const steps2 = [animate('500ms 500ms 500ms ease-out', style({opacity: 1}))];

      expect(() => {
        validateAndThrowAnimationSequence(steps2);
      }).toThrowError(/The provided timing value "500ms 500ms 500ms ease-out" is invalid/);
    });

    it('should throw if negative durations are used', () => {
      const steps = [animate(-1000, style({opacity: 1}))];

      expect(() => {
        validateAndThrowAnimationSequence(steps);
      }).toThrowError(/Duration values below 0 are not allowed for this animation step/);

      const steps2 = [animate('-1s', style({opacity: 1}))];

      expect(() => {
        validateAndThrowAnimationSequence(steps2);
      }).toThrowError(/Duration values below 0 are not allowed for this animation step/);
    });

    it('should throw if negative delays are used', () => {
      const steps = [animate('1s -500ms', style({opacity: 1}))];

      expect(() => {
        validateAndThrowAnimationSequence(steps);
      }).toThrowError(/Delay values below 0 are not allowed for this animation step/);

      const steps2 = [animate('1s -0.5s', style({opacity: 1}))];

      expect(() => {
        validateAndThrowAnimationSequence(steps2);
      }).toThrowError(/Delay values below 0 are not allowed for this animation step/);
    });

    it('should throw if keyframes() is not used inside of animate()', () => {
      const steps = [keyframes([])];

      expect(() => {
        validateAndThrowAnimationSequence(steps);
      }).toThrowError(/keyframes\(\) must be placed inside of a call to animate\(\)/);

      const steps2 = [group([keyframes([])])];

      expect(() => {
        validateAndThrowAnimationSequence(steps2);
      }).toThrowError(/keyframes\(\) must be placed inside of a call to animate\(\)/);
    });

    it('should throw if dynamic style substitutions are used without defaults within state() definitions', () => {
      const steps = [
        state(
          'final',
          style({
            'width': '{{ one }}px',
            'borderRadius': '{{ two }}px {{ three }}px',
          }),
        ),
      ];

      expect(() => {
        validateAndThrowAnimationSequence(steps);
      }).toThrowError(
        /state\("final", ...\) must define default values for all the following style substitutions: one, two, three/,
      );

      const steps2 = [
        state(
          'panfinal',
          style({
            'color': '{{ greyColor }}',
            'borderColor': '1px solid {{ greyColor }}',
            'backgroundColor': '{{ redColor }}',
          }),
          {params: {redColor: 'maroon'}},
        ),
      ];

      expect(() => {
        validateAndThrowAnimationSequence(steps2);
      }).toThrowError(
        /state\("panfinal", ...\) must define default values for all the following style substitutions: greyColor/,
      );
    });

    it('should provide a warning if an invalid CSS property is used in the animation', () => {
      const steps = [animate(1000, style({abc: '500px'}))];

      expect(getValidationWarningsForAnimationSequence(steps)).toEqual([
        'The following provided properties are not recognized: abc',
      ]);
    });

    it('should provide a warning if multiple invalid CSS properties are used in the animation', () => {
      const steps = [
        state(
          'state',
          style({
            '123': '100px',
          }),
        ),
        style({abc: '200px'}),
        animate(1000, style({xyz: '300px'})),
      ];

      expect(getValidationWarningsForAnimationSequence(steps)).toEqual([
        'The following provided properties are not recognized: 123, abc, xyz',
      ]);
    });

    it('should allow a vendor-prefixed property to be used in an animation sequence without throwing an error', () => {
      const steps = [
        style({webkitTransform: 'translateX(0px)'}),
        animate(1000, style({webkitTransform: 'translateX(100px)'})),
      ];

      expect(() => validateAndThrowAnimationSequence(steps)).not.toThrow();
    });

    it('should allow for old CSS properties (like transform) to be auto-prefixed by webkit', () => {
      const steps = [
        style({transform: 'translateX(-100px)'}),
        animate(1000, style({transform: 'translateX(500px)'})),
      ];

      expect(() => validateAndThrowAnimationSequence(steps)).not.toThrow();
    });
  });

  describe('keyframe building', () => {
    describe('style() / animate()', () => {
      it('should produce a balanced series of keyframes given a sequence of animate steps', () => {
        const steps = [
          style({width: 0}),
          animate(1000, style({height: 50})),
          animate(1000, style({width: 100})),
          animate(1000, style({height: 150})),
          animate(1000, style({width: 200})),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players[0].keyframes).toEqual([
          new Map<string, string | number>([
            ['height', AUTO_STYLE],
            ['width', 0],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['height', 50],
            ['width', 0],
            ['offset', 0.25],
          ]),
          new Map<string, string | number>([
            ['height', 50],
            ['width', 100],
            ['offset', 0.5],
          ]),
          new Map<string, string | number>([
            ['height', 150],
            ['width', 100],
            ['offset', 0.75],
          ]),
          new Map<string, string | number>([
            ['height', 150],
            ['width', 200],
            ['offset', 1],
          ]),
        ]);
      });

      it('should fill in missing starting steps when a starting `style()` value is not used', () => {
        const steps = [animate(1000, style({width: 999}))];

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players[0].keyframes).toEqual([
          new Map<string, string | number>([
            ['width', AUTO_STYLE],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['width', 999],
            ['offset', 1],
          ]),
        ]);
      });

      it('should merge successive style() calls together before an animate() call', () => {
        const steps = [
          style({width: 0}),
          style({height: 0}),
          style({width: 200}),
          style({opacity: 0}),
          animate(1000, style({width: 100, height: 400, opacity: 1})),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players[0].keyframes).toEqual([
          new Map<string, string | number>([
            ['width', 200],
            ['height', 0],
            ['opacity', 0],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['width', 100],
            ['height', 400],
            ['opacity', 1],
            ['offset', 1],
          ]),
        ]);
      });

      it('should not merge in successive style() calls to the previous animate() keyframe', () => {
        const steps = [
          style({opacity: 0}),
          animate(1000, style({opacity: 0.5})),
          style({opacity: 0.6}),
          animate(1000, style({opacity: 1})),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        const keyframes = humanizeOffsets(players[0].keyframes, 4);

        expect(keyframes).toEqual([
          new Map<string, string | number>([
            ['opacity', 0],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['opacity', 0.5],
            ['offset', 0.4998],
          ]),
          new Map<string, string | number>([
            ['opacity', 0.6],
            ['offset', 0.5002],
          ]),
          new Map<string, string | number>([
            ['opacity', 1],
            ['offset', 1],
          ]),
        ]);
      });

      it('should support an easing value that uses cubic-bezier(...)', () => {
        const steps = [
          style({opacity: 0}),
          animate('1s cubic-bezier(.29, .55 ,.53 ,1.53)', style({opacity: 1})),
        ];

        const player = invokeAnimationSequence(rootElement, steps)[0];
        const firstKeyframe = player.keyframes[0];
        const firstKeyframeEasing = firstKeyframe.get('easing') as string;
        expect(firstKeyframeEasing.replace(/\s+/g, '')).toEqual('cubic-bezier(.29,.55,.53,1.53)');
      });
    });

    describe('sequence()', () => {
      it('should not produce extra timelines when multiple sequences are used within each other', () => {
        const steps = [
          style({width: 0}),
          animate(1000, style({width: 100})),
          sequence([
            animate(1000, style({width: 200})),
            sequence([animate(1000, style({width: 300}))]),
          ]),
          animate(1000, style({width: 400})),
          sequence([animate(1000, style({width: 500}))]),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(1);

        const player = players[0];
        expect(player.keyframes).toEqual([
          new Map<string, string | number>([
            ['width', 0],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['width', 100],
            ['offset', 0.2],
          ]),
          new Map<string, string | number>([
            ['width', 200],
            ['offset', 0.4],
          ]),
          new Map<string, string | number>([
            ['width', 300],
            ['offset', 0.6],
          ]),
          new Map<string, string | number>([
            ['width', 400],
            ['offset', 0.8],
          ]),
          new Map<string, string | number>([
            ['width', 500],
            ['offset', 1],
          ]),
        ]);
      });

      it('should create a new timeline after a sequence if group() or keyframe() commands are used within', () => {
        const steps = [
          style({width: 100, height: 100}),
          animate(1000, style({width: 150, height: 150})),
          sequence([
            group([animate(1000, style({height: 200}))]),
            animate(1000, keyframes([style({width: 180}), style({width: 200})])),
          ]),
          animate(1000, style({width: 500, height: 500})),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(4);

        const finalPlayer = players[players.length - 1];
        expect(finalPlayer.keyframes).toEqual([
          new Map<string, string | number>([
            ['width', 200],
            ['height', 200],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['width', 500],
            ['height', 500],
            ['offset', 1],
          ]),
        ]);
      });

      it('should push the start of a sequence if a delay option is provided', () => {
        const steps = [
          style({width: '0px'}),
          animate(1000, style({width: '100px'})),
          sequence([animate(1000, style({width: '200px'}))], {delay: 500}),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        const finalPlayer = players[players.length - 1];
        expect(finalPlayer.keyframes).toEqual([
          new Map<string, string | number>([
            ['width', '100px'],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['width', '200px'],
            ['offset', 1],
          ]),
        ]);
        expect(finalPlayer.delay).toEqual(1500);
      });

      it('should allow a float-based delay value to be used', () => {
        let steps: any[] = [animate('.75s 0.75s', style({width: '300px'}))];

        let players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(1);

        let p1 = players.pop()!;
        expect(p1.duration).toEqual(1500);
        expect(p1.keyframes).toEqual([
          new Map<string, string | number>([
            ['width', '*'],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['width', '*'],
            ['offset', 0.5],
          ]),
          new Map<string, string | number>([
            ['width', '300px'],
            ['offset', 1],
          ]),
        ]);

        steps = [style({width: '100px'}), animate('.5s .5s', style({width: '200px'}))];

        players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(1);

        p1 = players.pop()!;
        expect(p1.duration).toEqual(1000);
        expect(p1.keyframes).toEqual([
          new Map<string, string | number>([
            ['width', '100px'],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['width', '100px'],
            ['offset', 0.5],
          ]),
          new Map<string, string | number>([
            ['width', '200px'],
            ['offset', 1],
          ]),
        ]);
      });
    });

    describe('substitutions', () => {
      it('should allow params to be substituted even if they are not defaulted in a reusable animation', () => {
        const myAnimation = animation([
          style({left: '{{ start }}'}),
          animate(1000, style({left: '{{ end }}'})),
        ]);

        const steps = [useAnimation(myAnimation, {params: {start: '0px', end: '100px'}})];

        const players = invokeAnimationSequence(rootElement, steps, {});
        expect(players.length).toEqual(1);
        const player = players[0];

        expect(player.keyframes).toEqual([
          new Map<string, string | number>([
            ['left', '0px'],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['left', '100px'],
            ['offset', 1],
          ]),
        ]);
      });

      it('should substitute in timing values', () => {
        function makeAnimation(exp: string, options: {[key: string]: any}) {
          const steps = [style({opacity: 0}), animate(exp, style({opacity: 1}))];
          return invokeAnimationSequence(rootElement, steps, options);
        }

        let players = makeAnimation('{{ duration }}', buildParams({duration: '1234ms'}));
        expect(players[0].duration).toEqual(1234);

        players = makeAnimation('{{ duration }}', buildParams({duration: '9s 2s'}));
        expect(players[0].duration).toEqual(11000);

        players = makeAnimation('{{ duration }} 1s', buildParams({duration: '1.5s'}));
        expect(players[0].duration).toEqual(2500);

        players = makeAnimation(
          '{{ duration }} {{ delay }}',
          buildParams({duration: '1s', delay: '2s'}),
        );
        expect(players[0].duration).toEqual(3000);
      });

      it('should allow multiple substitutions to occur within the same style value', () => {
        const steps = [
          style({borderRadius: '100px 100px'}),
          animate(1000, style({borderRadius: '{{ one }}px {{ two }}'})),
        ];
        const players = invokeAnimationSequence(
          rootElement,
          steps,
          buildParams({one: '200', two: '400px'}),
        );
        expect(players[0].keyframes).toEqual([
          new Map<string, string | number>([
            ['offset', 0],
            ['borderRadius', '100px 100px'],
          ]),
          new Map<string, string | number>([
            ['offset', 1],
            ['borderRadius', '200px 400px'],
          ]),
        ]);
      });

      it('should substitute in values that are defined as parameters for inner areas of a sequence', () => {
        const steps = sequence(
          [
            sequence(
              [
                sequence(
                  [style({height: '{{ x0 }}px'}), animate(1000, style({height: '{{ x2 }}px'}))],
                  buildParams({x2: '{{ x1 }}3'}),
                ),
              ],
              buildParams({x1: '{{ x0 }}2'}),
            ),
          ],
          buildParams({x0: '1'}),
        );

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(1);
        const [player] = players;
        expect(player.keyframes).toEqual([
          new Map<string, string | number>([
            ['offset', 0],
            ['height', '1px'],
          ]),
          new Map<string, string | number>([
            ['offset', 1],
            ['height', '123px'],
          ]),
        ]);
      });

      it('should substitute in values that are defined as parameters for reusable animations', () => {
        const anim = animation([
          style({height: '{{ start }}'}),
          animate(1000, style({height: '{{ end }}'})),
        ]);

        const steps = sequence(
          [
            sequence(
              [useAnimation(anim, buildParams({start: '{{ a }}', end: '{{ b }}'}))],
              buildParams({a: '100px', b: '200px'}),
            ),
          ],
          buildParams({a: '0px'}),
        );

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(1);
        const [player] = players;
        expect(player.keyframes).toEqual([
          new Map<string, string | number>([
            ['offset', 0],
            ['height', '100px'],
          ]),
          new Map<string, string | number>([
            ['offset', 1],
            ['height', '200px'],
          ]),
        ]);
      });

      it('should throw an error when an input variable is not provided when invoked and is not a default value', () => {
        expect(() =>
          invokeAnimationSequence(rootElement, [style({color: '{{ color }}'})]),
        ).toThrowError(/Please provide a value for the animation param color/);

        expect(() =>
          invokeAnimationSequence(
            rootElement,
            [style({color: '{{ start }}'}), animate('{{ time }}', style({color: '{{ end }}'}))],
            buildParams({start: 'blue', end: 'red'}),
          ),
        ).toThrowError(/Please provide a value for the animation param time/);

        expect(() =>
          invokeAnimationSequence(
            rootElement,
            [style({color: '{{ color }}'})],
            buildParams({color: undefined}),
          ),
        ).toThrowError(/Please provide a value for the animation param color/);

        expect(() =>
          invokeAnimationSequence(
            rootElement,
            [style({color: '{{ color }}'})],
            buildParams({color: null}),
          ),
        ).toThrowError(/Please provide a value for the animation param color/);
      });
    });

    describe('keyframes()', () => {
      it('should produce a sub timeline when `keyframes()` is used within a sequence', () => {
        const steps = [
          animate(1000, style({opacity: 0.5})),
          animate(1000, style({opacity: 1})),
          animate(1000, keyframes([style({height: 0}), style({height: 100}), style({height: 50})])),
          animate(1000, style({height: 0, opacity: 0})),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(3);

        const player0 = players[0];
        expect(player0.delay).toEqual(0);
        expect(player0.keyframes).toEqual([
          new Map<string, string | number>([
            ['opacity', AUTO_STYLE],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['opacity', 0.5],
            ['offset', 0.5],
          ]),
          new Map<string, string | number>([
            ['opacity', 1],
            ['offset', 1],
          ]),
        ]);

        const subPlayer = players[1];
        expect(subPlayer.delay).toEqual(2000);
        expect(subPlayer.keyframes).toEqual([
          new Map<string, string | number>([
            ['height', 0],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['height', 100],
            ['offset', 0.5],
          ]),
          new Map<string, string | number>([
            ['height', 50],
            ['offset', 1],
          ]),
        ]);

        const player1 = players[2];
        expect(player1.delay).toEqual(3000);
        expect(player1.keyframes).toEqual([
          new Map<string, string | number>([
            ['opacity', 1],
            ['height', 50],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['opacity', 0],
            ['height', 0],
            ['offset', 1],
          ]),
        ]);
      });

      it('should propagate inner keyframe style data to the parent timeline if used afterwards', () => {
        const steps = [
          style({opacity: 0}),
          animate(1000, style({opacity: 0.5})),
          animate(1000, style({opacity: 1})),
          animate(1000, keyframes([style({color: 'red'}), style({color: 'blue'})])),
          animate(1000, style({color: 'green', opacity: 0})),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        const finalPlayer = players[players.length - 1];
        expect(finalPlayer.keyframes).toEqual([
          new Map<string, string | number>([
            ['opacity', 1],
            ['color', 'blue'],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['opacity', 0],
            ['color', 'green'],
            ['offset', 1],
          ]),
        ]);
      });

      it('should feed in starting data into inner keyframes if used in an style step beforehand', () => {
        const steps = [
          animate(1000, style({opacity: 0.5})),
          animate(
            1000,
            keyframes([style({opacity: 0.8, offset: 0.5}), style({opacity: 1, offset: 1})]),
          ),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(2);

        const topPlayer = players[0];
        expect(topPlayer.keyframes).toEqual([
          new Map<string, string | number>([
            ['opacity', AUTO_STYLE],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['opacity', 0.5],
            ['offset', 1],
          ]),
        ]);

        const subPlayer = players[1];
        expect(subPlayer.keyframes).toEqual([
          new Map<string, string | number>([
            ['opacity', 0.5],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['opacity', 0.8],
            ['offset', 0.5],
          ]),
          new Map<string, string | number>([
            ['opacity', 1],
            ['offset', 1],
          ]),
        ]);
      });

      it('should set the easing value as an easing value for the entire timeline', () => {
        const steps = [
          style({opacity: 0}),
          animate(1000, style({opacity: 0.5})),
          animate(
            '1s ease-out',
            keyframes([style({opacity: 0.8, offset: 0.5}), style({opacity: 1, offset: 1})]),
          ),
        ];

        const player = invokeAnimationSequence(rootElement, steps)[1];
        expect(player.easing).toEqual('ease-out');
      });

      it('should combine the starting time + the given delay as the delay value for the animated keyframes', () => {
        const steps = [
          style({opacity: 0}),
          animate(500, style({opacity: 0.5})),
          animate(
            '1s 2s ease-out',
            keyframes([style({opacity: 0.8, offset: 0.5}), style({opacity: 1, offset: 1})]),
          ),
        ];

        const player = invokeAnimationSequence(rootElement, steps)[1];
        expect(player.delay).toEqual(2500);
      });

      it('should not leak in additional styles used later on after keyframe styles have already been declared', () => {
        const steps = [
          animate(1000, style({height: '50px'})),
          animate(
            2000,
            keyframes([
              style({left: '0', top: '0', offset: 0}),
              style({left: '40%', top: '50%', offset: 0.33}),
              style({left: '60%', top: '80%', offset: 0.66}),
              style({left: 'calc(100% - 100px)', top: '100%', offset: 1}),
            ]),
          ),
          group([animate('2s', style({width: '200px'}))]),
          animate('2s', style({height: '300px'})),
          group([animate('2s', style({height: '500px', width: '500px'}))]),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(5);

        const firstPlayerKeyframes = players[0].keyframes;
        expect(firstPlayerKeyframes[0].get('width')).toBeFalsy();
        expect(firstPlayerKeyframes[1].get('width')).toBeFalsy();
        expect(firstPlayerKeyframes[0].get('height')).toEqual(AUTO_STYLE);
        expect(firstPlayerKeyframes[1].get('height')).toEqual('50px');

        const keyframePlayerKeyframes = players[1].keyframes;
        expect(keyframePlayerKeyframes[0].get('width')).toBeFalsy();
        expect(keyframePlayerKeyframes[0].get('height')).toBeFalsy();

        const groupPlayerKeyframes = players[2].keyframes;
        expect(groupPlayerKeyframes[0].get('width')).toEqual(AUTO_STYLE);
        expect(groupPlayerKeyframes[1].get('width')).toEqual('200px');
        expect(groupPlayerKeyframes[0].get('height')).toBeFalsy();
        expect(groupPlayerKeyframes[1].get('height')).toBeFalsy();

        const secondToFinalAnimatePlayerKeyframes = players[3].keyframes;
        expect(secondToFinalAnimatePlayerKeyframes[0].get('width')).toBeFalsy();
        expect(secondToFinalAnimatePlayerKeyframes[1].get('width')).toBeFalsy();
        expect(secondToFinalAnimatePlayerKeyframes[0].get('height')).toEqual('50px');
        expect(secondToFinalAnimatePlayerKeyframes[1].get('height')).toEqual('300px');

        const finalAnimatePlayerKeyframes = players[4].keyframes;
        expect(finalAnimatePlayerKeyframes[0].get('width')).toEqual('200px');
        expect(finalAnimatePlayerKeyframes[1].get('width')).toEqual('500px');
        expect(finalAnimatePlayerKeyframes[0].get('height')).toEqual('300px');
        expect(finalAnimatePlayerKeyframes[1].get('height')).toEqual('500px');
      });

      it('should respect offsets if provided directly within the style data', () => {
        const steps = animate(
          1000,
          keyframes([
            style({opacity: 0, offset: 0}),
            style({opacity: 0.6, offset: 0.6}),
            style({opacity: 1, offset: 1}),
          ]),
        );

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(1);
        const player = players[0];

        expect(player.keyframes).toEqual([
          new Map<string, string | number>([
            ['opacity', 0],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['opacity', 0.6],
            ['offset', 0.6],
          ]),
          new Map<string, string | number>([
            ['opacity', 1],
            ['offset', 1],
          ]),
        ]);
      });

      it('should respect offsets if provided directly within the style metadata type', () => {
        const steps = animate(
          1000,
          keyframes([
            {type: AnimationMetadataType.Style, offset: 0, styles: {opacity: 0}},
            {type: AnimationMetadataType.Style, offset: 0.4, styles: {opacity: 0.4}},
            {type: AnimationMetadataType.Style, offset: 1, styles: {opacity: 1}},
          ]),
        );

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(1);
        const player = players[0];

        expect(player.keyframes).toEqual([
          new Map<string, string | number>([
            ['opacity', 0],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['opacity', 0.4],
            ['offset', 0.4],
          ]),
          new Map<string, string | number>([
            ['opacity', 1],
            ['offset', 1],
          ]),
        ]);
      });
    });

    describe('group()', () => {
      it('should properly tally style data within a group() for use in a follow-up animate() step', () => {
        const steps = [
          style({width: 0, height: 0}),
          animate(1000, style({width: 20, height: 50})),
          group([animate('1s 1s', style({width: 200})), animate('1s', style({height: 500}))]),
          animate(1000, style({width: 1000, height: 1000})),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(4);

        const player0 = players[0];
        expect(player0.duration).toEqual(1000);
        expect(player0.keyframes).toEqual([
          new Map<string, string | number>([
            ['width', 0],
            ['height', 0],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['width', 20],
            ['height', 50],
            ['offset', 1],
          ]),
        ]);

        const gPlayer1 = players[1];
        expect(gPlayer1.duration).toEqual(2000);
        expect(gPlayer1.delay).toEqual(1000);
        expect(gPlayer1.keyframes).toEqual([
          new Map<string, string | number>([
            ['width', 20],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['width', 20],
            ['offset', 0.5],
          ]),
          new Map<string, string | number>([
            ['width', 200],
            ['offset', 1],
          ]),
        ]);

        const gPlayer2 = players[2];
        expect(gPlayer2.duration).toEqual(1000);
        expect(gPlayer2.delay).toEqual(1000);
        expect(gPlayer2.keyframes).toEqual([
          new Map<string, string | number>([
            ['height', 50],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['height', 500],
            ['offset', 1],
          ]),
        ]);

        const player1 = players[3];
        expect(player1.duration).toEqual(1000);
        expect(player1.delay).toEqual(3000);
        expect(player1.keyframes).toEqual([
          new Map<string, string | number>([
            ['width', 200],
            ['height', 500],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['width', 1000],
            ['height', 1000],
            ['offset', 1],
          ]),
        ]);
      });

      it('should support groups with nested sequences', () => {
        const steps = [
          group([
            sequence([style({opacity: 0}), animate(1000, style({opacity: 1}))]),
            sequence([style({width: 0}), animate(1000, style({width: 200}))]),
          ]),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(2);

        const gPlayer1 = players[0];
        expect(gPlayer1.delay).toEqual(0);
        expect(gPlayer1.keyframes).toEqual([
          new Map<string, string | number>([
            ['opacity', 0],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['opacity', 1],
            ['offset', 1],
          ]),
        ]);

        const gPlayer2 = players[1];
        expect(gPlayer1.delay).toEqual(0);
        expect(gPlayer2.keyframes).toEqual([
          new Map<string, string | number>([
            ['width', 0],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['width', 200],
            ['offset', 1],
          ]),
        ]);
      });

      it('should respect delays after group entries', () => {
        const steps = [
          style({width: 0, height: 0}),
          animate(1000, style({width: 50, height: 50})),
          group([animate(1000, style({width: 100})), animate(1000, style({height: 100}))]),
          animate('1s 1s', style({height: 200, width: 200})),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(4);

        const finalPlayer = players[players.length - 1];
        expect(finalPlayer.delay).toEqual(2000);
        expect(finalPlayer.duration).toEqual(2000);
        expect(finalPlayer.keyframes).toEqual([
          new Map<string, string | number>([
            ['width', 100],
            ['height', 100],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['width', 100],
            ['height', 100],
            ['offset', 0.5],
          ]),
          new Map<string, string | number>([
            ['width', 200],
            ['height', 200],
            ['offset', 1],
          ]),
        ]);
      });

      it('should respect delays after multiple calls to group()', () => {
        const steps = [
          group([animate('2s', style({opacity: 1})), animate('2s', style({width: '100px'}))]),
          animate(2000, style({width: 0, opacity: 0})),
          group([animate('2s', style({opacity: 1})), animate('2s', style({width: '200px'}))]),
          animate(2000, style({width: 0, opacity: 0})),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        const middlePlayer = players[2];
        expect(middlePlayer.delay).toEqual(2000);
        expect(middlePlayer.duration).toEqual(2000);

        const finalPlayer = players[players.length - 1];
        expect(finalPlayer.delay).toEqual(6000);
        expect(finalPlayer.duration).toEqual(2000);
      });

      it('should push the start of a group if a delay option is provided', () => {
        const steps = [
          style({width: '0px', height: '0px'}),
          animate(1500, style({width: '100px', height: '100px'})),
          group([animate(1000, style({width: '200px'})), animate(2000, style({height: '200px'}))], {
            delay: 300,
          }),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        const finalWidthPlayer = players[players.length - 2];
        const finalHeightPlayer = players[players.length - 1];

        expect(finalWidthPlayer.delay).toEqual(1800);
        expect(finalWidthPlayer.keyframes).toEqual([
          new Map<string, string | number>([
            ['width', '100px'],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['width', '200px'],
            ['offset', 1],
          ]),
        ]);

        expect(finalHeightPlayer.delay).toEqual(1800);
        expect(finalHeightPlayer.keyframes).toEqual([
          new Map<string, string | number>([
            ['height', '100px'],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['height', '200px'],
            ['offset', 1],
          ]),
        ]);
      });
    });

    describe('query()', () => {
      it('should delay the query operation if a delay option is provided (1)', () => {
        const steps = [
          style({opacity: 0}),
          animate(1000, style({opacity: 1})),
          query('div', [style({width: 0}), animate(500, style({width: 200}))], {delay: 200}),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        const finalPlayer = players[players.length - 1];
        expect(finalPlayer.delay).toEqual(1200);
      });

      it('should throw an error when an animation query returns zero elements', () => {
        const steps = [
          query('somethingFake', [style({opacity: 0}), animate(1000, style({opacity: 1}))]),
        ];

        expect(() => {
          invokeAnimationSequence(rootElement, steps);
        }).toThrowError(
          /`query\("somethingFake"\)` returned zero elements\. \(Use `query\("somethingFake", \{ optional: true \}\)` if you wish to allow this\.\)/,
        );
      });

      it('should allow a query to be skipped if it is set as optional and returns zero elements', () => {
        const steps = [
          query('somethingFake', [style({opacity: 0}), animate(1000, style({opacity: 1}))], {
            optional: true,
          }),
        ];

        expect(() => {
          invokeAnimationSequence(rootElement, steps);
        }).not.toThrow();

        const steps2 = [
          query('fakeSomethings', [style({opacity: 0}), animate(1000, style({opacity: 1}))], {
            optional: true,
          }),
        ];

        expect(() => {
          invokeAnimationSequence(rootElement, steps2);
        }).not.toThrow();
      });

      it('should delay the query operation if a delay option is provided (2)', () => {
        const steps = [
          style({opacity: 0}),
          animate(1300, style({opacity: 1})),
          query('div', [style({width: 0}), animate(500, style({width: 200}))], {delay: 300}),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        const fp1 = players[players.length - 2];
        const fp2 = players[players.length - 1];
        expect(fp1.delay).toEqual(1600);
        expect(fp2.delay).toEqual(1600);
      });
    });

    describe('timing values', () => {
      it('should properly combine an easing value with a delay into a set of three keyframes', () => {
        const steps: AnimationMetadata[] = [
          style({opacity: 0}),
          animate('3s 1s ease-out', style({opacity: 1})),
        ];

        const player = invokeAnimationSequence(rootElement, steps)[0];
        expect(player.keyframes).toEqual([
          new Map<string, string | number>([
            ['opacity', 0],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['opacity', 0],
            ['offset', 0.25],
            ['easing', 'ease-out'],
          ]),
          new Map<string, string | number>([
            ['opacity', 1],
            ['offset', 1],
          ]),
        ]);
      });

      it('should allow easing values to exist for each animate() step', () => {
        const steps: AnimationMetadata[] = [
          style({width: 0}),
          animate('1s linear', style({width: 10})),
          animate('2s ease-out', style({width: 20})),
          animate('1s ease-in', style({width: 30})),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players.length).toEqual(1);

        const player = players[0];
        expect(player.keyframes).toEqual([
          new Map<string, string | number>([
            ['width', 0],
            ['offset', 0],
            ['easing', 'linear'],
          ]),
          new Map<string, string | number>([
            ['width', 10],
            ['offset', 0.25],
            ['easing', 'ease-out'],
          ]),
          new Map<string, string | number>([
            ['width', 20],
            ['offset', 0.75],
            ['easing', 'ease-in'],
          ]),
          new Map<string, string | number>([
            ['width', 30],
            ['offset', 1],
          ]),
        ]);
      });

      it('should produce a top-level timeline only for the duration that is set as before a group kicks in', () => {
        const steps: AnimationMetadata[] = [
          style({width: 0, height: 0, opacity: 0}),
          animate('1s', style({width: 100, height: 100, opacity: 0.2})),
          group([
            animate('500ms 1s', style({width: 500})),
            animate('1s', style({height: 500})),
            sequence([
              animate(500, style({opacity: 0.5})),
              animate(500, style({opacity: 0.6})),
              animate(500, style({opacity: 0.7})),
              animate(500, style({opacity: 1})),
            ]),
          ]),
        ];

        const player = invokeAnimationSequence(rootElement, steps)[0];
        expect(player.duration).toEqual(1000);
        expect(player.delay).toEqual(0);
      });

      it('should offset group() and keyframe() timelines with a delay which is the current time of the previous player when called', () => {
        const steps: AnimationMetadata[] = [
          style({width: 0, height: 0}),
          animate('1500ms linear', style({width: 10, height: 10})),
          group([
            animate(1000, style({width: 500, height: 500})),
            animate(2000, style({width: 500, height: 500})),
          ]),
          animate(1000, keyframes([style({width: 200}), style({width: 500})])),
        ];

        const players = invokeAnimationSequence(rootElement, steps);
        expect(players[0].delay).toEqual(0); // top-level animation
        expect(players[1].delay).toEqual(1500); // first entry in group()
        expect(players[2].delay).toEqual(1500); // second entry in group()
        expect(players[3].delay).toEqual(3500); // animate(...keyframes())
      });
    });

    describe('state based data', () => {
      it('should create an empty animation if there are zero animation steps', () => {
        const steps: AnimationMetadata[] = [];

        const fromStyles: Array<ɵStyleDataMap> = [
          new Map<string, string | number>([
            ['background', 'blue'],
            ['height', 100],
          ]),
        ];

        const toStyles: Array<ɵStyleDataMap> = [
          new Map<string, string | number>([['background', 'red']]),
        ];

        const player = invokeAnimationSequence(rootElement, steps, {}, fromStyles, toStyles)[0];
        expect(player.duration).toEqual(0);
        expect(player.keyframes).toEqual([]);
      });

      it('should produce an animation from start to end between the to and from styles if there are animate steps in between', () => {
        const steps: AnimationMetadata[] = [animate(1000)];

        const fromStyles: Array<ɵStyleDataMap> = [
          new Map<string, string | number>([
            ['background', 'blue'],
            ['height', 100],
          ]),
        ];

        const toStyles: Array<ɵStyleDataMap> = [
          new Map<string, string | number>([['background', 'red']]),
        ];

        const players = invokeAnimationSequence(rootElement, steps, {}, fromStyles, toStyles);
        expect(players[0].keyframes).toEqual([
          new Map<string, string | number>([
            ['background', 'blue'],
            ['height', 100],
            ['offset', 0],
          ]),
          new Map<string, string | number>([
            ['background', 'red'],
            ['height', AUTO_STYLE],
            ['offset', 1],
          ]),
        ]);
      });

      it('should produce an animation from start to end between the to and from styles if there are animate steps in between with an easing value', () => {
        const steps: AnimationMetadata[] = [animate('1s ease-out')];

        const fromStyles: Array<ɵStyleDataMap> = [
          new Map<string, string | number>([['background', 'blue']]),
        ];

        const toStyles: Array<ɵStyleDataMap> = [
          new Map<string, string | number>([['background', 'red']]),
        ];

        const players = invokeAnimationSequence(rootElement, steps, {}, fromStyles, toStyles);
        expect(players[0].keyframes).toEqual([
          new Map<string, string | number>([
            ['background', 'blue'],
            ['offset', 0],
            ['easing', 'ease-out'],
          ]),
          new Map<string, string | number>([
            ['background', 'red'],
            ['offset', 1],
          ]),
        ]);
      });
    });
  });
});

function humanizeOffsets(
  keyframes: Array<ɵStyleDataMap>,
  digits: number = 3,
): Array<ɵStyleDataMap> {
  return keyframes.map((keyframe) => {
    keyframe.set('offset', Number(parseFloat(<any>keyframe.get('offset')).toFixed(digits)));
    return keyframe;
  });
}

function invokeAnimationSequence(
  element: any,
  steps: AnimationMetadata | AnimationMetadata[],
  locals: {[key: string]: any} = {},
  startingStyles: Array<ɵStyleDataMap> = [],
  destinationStyles: Array<ɵStyleDataMap> = [],
  subInstructions?: ElementInstructionMap,
): AnimationTimelineInstruction[] {
  const driver = new MockAnimationDriver();
  return new Animation(driver, steps).buildTimelines(
    element,
    startingStyles,
    destinationStyles,
    locals,
    subInstructions,
  );
}

function validateAndThrowAnimationSequence(steps: AnimationMetadata | AnimationMetadata[]) {
  const driver = new MockAnimationDriver();
  const errors: Error[] = [];
  const ast = buildAnimationAst(driver, steps, errors, []);
  if (errors.length) {
    throw new Error(errors.join('\n'));
  }
}

function getValidationWarningsForAnimationSequence(
  steps: AnimationMetadata | AnimationMetadata[],
): string[] {
  const driver = new MockAnimationDriver();
  const warnings: string[] = [];
  buildAnimationAst(driver, steps, [], warnings);
  return warnings;
}

function buildParams(params: {[name: string]: any}): AnimationOptions {
  return <AnimationOptions>{params};
}
