<!DOCTYPE html>
<meta charset=utf-8>
<title>Processing a keyframes argument (property access)</title>
<link rel="help" href="https://drafts.csswg.org/web-animations/#processing-a-keyframes-argument">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="../../testcommon.js"></script>
<script src="../../resources/keyframe-utils.js"></script>
<body>
<div id="log"></div>
<div id="target"></div>
<script>
'use strict';

// This file only tests the KeyframeEffect constructor since it is
// assumed that the implementation of the KeyframeEffect constructor,
// Animatable.animate() method, and KeyframeEffect.setKeyframes() method will
// all share common machinery and it is not necessary to test each method.

// Test that only animatable properties are accessed

const gNonAnimatableProps = [
  'animation', // Shorthands where all the longhand sub-properties are not
               // animatable, are also not animatable.
  'animationDelay',
  'animationDirection',
  'animationDuration',
  'animationFillMode',
  'animationIterationCount',
  'animationName',
  'animationPlayState',
  'animationTimingFunction',
  'transition',
  'transitionDelay',
  'transitionDuration',
  'transitionProperty',
  'transitionTimingFunction',
  'contain',
  'direction',
  'display',
  'textCombineUpright',
  'textOrientation',
  'unicodeBidi',
  'willChange',
  'writingMode',

  'unsupportedProperty',

  'float', // We use the string "cssFloat" to represent "float" property, and
           // so reject "float" in the keyframe-like object.
  'font-size', // Supported property that uses dashes
];

function TestKeyframe(testProp) {
  let _propAccessCount = 0;

  Object.defineProperty(this, testProp, {
    get: () => { _propAccessCount++; },
    enumerable: true,
  });

  Object.defineProperty(this, 'propAccessCount', {
    get: () => _propAccessCount
  });
}

function GetTestKeyframeSequence(testProp) {
  return [ new TestKeyframe(testProp) ]
}

for (const prop of gNonAnimatableProps) {
  test(() => {
    const testKeyframe = new TestKeyframe(prop);

    new KeyframeEffect(null, testKeyframe);

    assert_equals(testKeyframe.propAccessCount, 0, 'Accessor not called');
  }, `non-animatable property '${prop}' is not accessed when using`
     + ' a property-indexed keyframe object');
}

for (const prop of gNonAnimatableProps) {
  test(() => {
    const testKeyframes = GetTestKeyframeSequence(prop);

    new KeyframeEffect(null, testKeyframes);

    assert_equals(testKeyframes[0].propAccessCount, 0, 'Accessor not called');
  }, `non-animatable property '${prop}' is not accessed when using`
     + ' a keyframe sequence');
}

// Test equivalent forms of property-indexed and sequenced keyframe syntax

function assertEquivalentKeyframeSyntax(keyframesA, keyframesB) {
  const processedKeyframesA =
    new KeyframeEffect(null, keyframesA).getKeyframes();
  const processedKeyframesB =
    new KeyframeEffect(null, keyframesB).getKeyframes();
  assert_frame_lists_equal(processedKeyframesA, processedKeyframesB);
}

const gEquivalentSyntaxTests = [
  {
    description: 'two properties with one value',
    indexedKeyframes: {
      left: '100px',
      opacity: ['1'],
    },
    sequencedKeyframes: [
      { left: '100px', opacity: '1' },
    ],
  },
  {
    description: 'two properties with three values',
    indexedKeyframes: {
      left: ['10px', '100px', '150px'],
      opacity: ['1', '0', '1'],
    },
    sequencedKeyframes: [
      { left: '10px', opacity: '1' },
      { left: '100px', opacity: '0' },
      { left: '150px', opacity: '1' },
    ],
  },
  {
    description: 'two properties with different numbers of values',
    indexedKeyframes: {
      left: ['0px', '100px', '200px'],
      opacity: ['0', '1']
    },
    sequencedKeyframes: [
      { left: '0px', opacity: '0' },
      { left: '100px' },
      { left: '200px', opacity: '1' },
    ],
  },
  {
    description: 'same easing applied to all keyframes',
    indexedKeyframes: {
      left: ['10px', '100px', '150px'],
      opacity: ['1', '0', '1'],
      easing: 'ease',
    },
    sequencedKeyframes: [
      { left: '10px', opacity: '1', easing: 'ease' },
      { left: '100px', opacity: '0', easing: 'ease' },
      { left: '150px', opacity: '1', easing: 'ease' },
    ],
  },
  {
    description: 'same composite applied to all keyframes',
    indexedKeyframes: {
      left: ['0px', '100px'],
      composite: 'add',
    },
    sequencedKeyframes: [
      { left: '0px', composite: 'add' },
      { left: '100px', composite: 'add' },
    ],
  },
];

for (const {description, indexedKeyframes, sequencedKeyframes} of
     gEquivalentSyntaxTests) {
  test(() => {
    assertEquivalentKeyframeSyntax(indexedKeyframes, sequencedKeyframes);
  }, `Equivalent property-indexed and sequenced keyframes: ${description}`);
}

// Test handling of custom iterable objects.

function createIterable(iterations) {
  return {
    [Symbol.iterator]() {
      let i = 0;
      return {
        next() {
          return iterations[i++];
        },
      };
    },
  };
}

test(() => {
  const effect = new KeyframeEffect(null, createIterable([
    { done: false, value: { left: '100px' } },
    { done: false, value: { left: '300px' } },
    { done: false, value: { left: '200px' } },
    { done: true },
  ]));
  assert_frame_lists_equal(effect.getKeyframes(), [
    {
      offset: null,
      computedOffset: 0,
      easing: 'linear',
      left: '100px',
      composite: 'auto',
    },
    {
      offset: null,
      computedOffset: 0.5,
      easing: 'linear',
      left: '300px',
      composite: 'auto',
    },
    {
      offset: null,
      computedOffset: 1,
      easing: 'linear',
      left: '200px',
      composite: 'auto',
    },
  ]);
}, 'Keyframes are read from a custom iterator');

test(() => {
  const keyframes = createIterable([
    { done: false, value: { left: '100px' } },
    { done: false, value: { left: '300px' } },
    { done: false, value: { left: '200px' } },
    { done: true },
  ]);
  keyframes.easing = 'ease-in-out';
  keyframes.offset = '0.1';
  const effect = new KeyframeEffect(null, keyframes);
  assert_frame_lists_equal(effect.getKeyframes(), [
    {
      offset: null,
      computedOffset: 0,
      easing: 'linear',
      left: '100px',
      composite: 'auto',
    },
    {
      offset: null,
      computedOffset: 0.5,
      easing: 'linear',
      left: '300px',
      composite: 'auto',
    },
    {
      offset: null,
      computedOffset: 1,
      easing: 'linear',
      left: '200px',
      composite: 'auto',
    },
  ]);
}, '\'easing\' and \'offset\' are ignored on iterable objects');

test(() => {
  const effect = new KeyframeEffect(null, createIterable([
    { done: false, value: { left: '100px', top: '200px' } },
    { done: false, value: { left: '300px' } },
    { done: false, value: { left: '200px', top: '100px' } },
    { done: true },
  ]));
  assert_frame_lists_equal(effect.getKeyframes(), [
    {
      offset: null,
      computedOffset: 0,
      easing: 'linear',
      left: '100px',
      top: '200px',
      composite: 'auto',
    },
    {
      offset: null,
      computedOffset: 0.5,
      easing: 'linear',
      left: '300px',
      composite: 'auto',
    },
    {
      offset: null,
      computedOffset: 1,
      easing: 'linear',
      left: '200px',
      top: '100px',
      composite: 'auto',
    },
  ]);
}, 'Keyframes are read from a custom iterator with multiple properties'
   + ' specified');

test(() => {
  const effect = new KeyframeEffect(null, createIterable([
    { done: false, value: { left: '100px' } },
    { done: false, value: { left: '250px', offset: 0.75 } },
    { done: false, value: { left: '200px' } },
    { done: true },
  ]));
  assert_frame_lists_equal(effect.getKeyframes(), [
    {
      offset: null,
      computedOffset: 0,
      easing: 'linear',
      left: '100px',
      composite: 'auto',
    },
    {
      offset: 0.75,
      computedOffset: 0.75,
      easing: 'linear',
      left: '250px',
      composite: 'auto',
    },
    {
      offset: null,
      computedOffset: 1,
      easing: 'linear',
      left: '200px',
      composite: 'auto',
    },
  ]);
}, 'Keyframes are read from a custom iterator with where an offset is'
   + ' specified');

test(() => {
  const test_error = { name: 'test' };
  const bad_keyframe = { get left() { throw test_error; } };
  assert_throws_exactly(test_error, () => {
    new KeyframeEffect(null, createIterable([
      { done: false, value: { left: '100px' } },
      { done: false, value: bad_keyframe },
      { done: false, value: { left: '200px' } },
      { done: true },
    ]));
  });
}, 'If a keyframe throws for an animatable property, that exception should be'
    + ' propagated');

test(() => {
  assert_throws_js(TypeError, () => {
    new KeyframeEffect(null, createIterable([
      { done: false, value: { left: '100px' } },
      { done: false, value: 1234 },
      { done: false, value: { left: '200px' } },
      { done: true },
    ]));
  });
}, 'Reading from a custom iterator that returns a non-object keyframe'
   + ' should throw');

test(() => {
  assert_throws_js(TypeError, () => {
    new KeyframeEffect(null, createIterable([
      { done: false, value: { left: '100px', easing: '' } },
      { done: false, value: 1234 },
      { done: false, value: { left: '200px' } },
      { done: true },
    ]));
  });
}, 'Reading from a custom iterator that returns a non-object keyframe'
   + ' and an invalid easing should throw');

test(() => {
  assert_throws_js(TypeError, () => {
    new KeyframeEffect(null, createIterable([
      { done: false, value: { left: '100px' } },
      { done: false, value: { left: '150px', offset: 'o' } },
      { done: false, value: { left: '200px' } },
      { done: true },
    ]));
  });
}, 'Reading from a custom iterator that returns a keyframe with a non finite'
   + ' floating-point offset value should throw');

test(() => {
  assert_throws_js(TypeError, () => {
    new KeyframeEffect(null, createIterable([
      { done: false, value: { left: '100px', easing: '' } },
      { done: false, value: { left: '150px', offset: 'o' } },
      { done: false, value: { left: '200px' } },
      { done: true },
    ]));
  });
}, 'Reading from a custom iterator that returns a keyframe with a non finite'
   + ' floating-point offset value and an invalid easing should throw');

test(() => {
  const effect = new KeyframeEffect(null, createIterable([
    { done: false, value: { left: '100px' } },
    { done: false },  // No value member; keyframe is undefined.
    { done: false, value: { left: '200px' } },
    { done: true },
  ]));
  assert_frame_lists_equal(effect.getKeyframes(), [
    { left: '100px', offset: null, computedOffset: 0, easing: 'linear', composite: 'auto' },
    { offset: null, computedOffset: 0.5, easing: 'linear', composite: 'auto' },
    { left: '200px', offset: null, computedOffset: 1, easing: 'linear', composite: 'auto' },
  ]);
}, 'An undefined keyframe returned from a custom iterator should be treated as a'
    + ' default keyframe');

test(() => {
  const effect = new KeyframeEffect(null, createIterable([
    { done: false, value: { left: '100px' } },
    { done: false, value: null },
    { done: false, value: { left: '200px' } },
    { done: true },
  ]));
  assert_frame_lists_equal(effect.getKeyframes(), [
    { left: '100px', offset: null, computedOffset: 0, easing: 'linear', composite: 'auto' },
    { offset: null, computedOffset: 0.5, easing: 'linear', composite: 'auto' },
    { left: '200px', offset: null, computedOffset: 1, easing: 'linear', composite: 'auto' },
  ]);
}, 'A null keyframe returned from a custom iterator should be treated as a'
    + ' default keyframe');

test(() => {
  const effect = new KeyframeEffect(null, createIterable([
    { done: false, value: { left: ['100px', '200px'] } },
    { done: true },
  ]));
  assert_frame_lists_equal(effect.getKeyframes(), [
    { offset: null, computedOffset: 1, easing: 'linear', composite: 'auto' }
  ]);
}, 'A list of values returned from a custom iterator should be ignored');

test(() => {
  const test_error = { name: 'test' };
  const keyframe_obj = {
    [Symbol.iterator]() {
      return { next() { throw test_error; } };
    },
  };
  assert_throws_exactly(test_error, () => {
    new KeyframeEffect(null, keyframe_obj);
  });
}, 'If a custom iterator throws from next(), the exception should be rethrown');

// Test handling of invalid Symbol.iterator

test(() => {
  const test_error = { name: 'test' };
  const keyframe_obj = {
    [Symbol.iterator]() {
      throw test_error;
    },
  };
  assert_throws_exactly(test_error, () => {
    new KeyframeEffect(null, keyframe_obj);
  });
}, 'Accessing a Symbol.iterator property that throws should rethrow');

test(() => {
  const keyframe_obj = {
    [Symbol.iterator]() {
      return 42;  // Not an object.
    },
  };
  assert_throws_js(TypeError, () => {
    new KeyframeEffect(null, keyframe_obj);
  });
}, 'A non-object returned from the Symbol.iterator property should cause a'
    + ' TypeError to be thrown');

test(() => {
  const keyframe = {};
  Object.defineProperty(keyframe, 'width', { value: '200px' });
  Object.defineProperty(keyframe, 'height', {
    value: '100px',
    enumerable: true,
  });
  assert_equals(keyframe.width, '200px', 'width of keyframe is readable');
  assert_equals(keyframe.height, '100px', 'height of keyframe is readable');

  const effect = new KeyframeEffect(null, [keyframe, { height: '200px' }]);

  assert_frame_lists_equal(effect.getKeyframes(), [
    {
      offset: null,
      computedOffset: 0,
      easing: 'linear',
      height: '100px',
      composite: 'auto',
    },
    {
      offset: null,
      computedOffset: 1,
      easing: 'linear',
      height: '200px',
      composite: 'auto',
    },
  ]);
}, 'Only enumerable properties on keyframes are read');

test(() => {
  const KeyframeParent = function() { this.width = '100px'; };
  KeyframeParent.prototype = { height: '100px' };
  const Keyframe = function() { this.top = '100px'; };
  Keyframe.prototype = Object.create(KeyframeParent.prototype);
  Object.defineProperty(Keyframe.prototype, 'left', {
    value: '100px',
    enumerable: true,
  });
  const keyframe = new Keyframe();

  const effect = new KeyframeEffect(null, [keyframe, { top: '200px' }]);

  assert_frame_lists_equal(effect.getKeyframes(), [
    {
      offset: null,
      computedOffset: 0,
      easing: 'linear',
      top: '100px',
      composite: 'auto',
    },
    {
      offset: null,
      computedOffset: 1,
      easing: 'linear',
      top: '200px',
      composite: 'auto',
    },
  ]);
}, 'Only properties defined directly on keyframes are read');

test(() => {
  const keyframes = {};
  Object.defineProperty(keyframes, 'width', ['100px', '200px']);
  Object.defineProperty(keyframes, 'height', {
    value: ['100px', '200px'],
    enumerable: true,
  });

  const effect = new KeyframeEffect(null, keyframes);

  assert_frame_lists_equal(effect.getKeyframes(), [
    {
      offset: null,
      computedOffset: 0,
      easing: 'linear',
      height: '100px',
      composite: 'auto',
    },
    {
      offset: null,
      computedOffset: 1,
      easing: 'linear',
      height: '200px',
      composite: 'auto',
    },
  ]);
}, 'Only enumerable properties on property-indexed keyframes are read');

test(() => {
  const KeyframesParent = function() { this.width = '100px'; };
  KeyframesParent.prototype = { height: '100px' };
  const Keyframes = function() { this.top = ['100px', '200px']; };
  Keyframes.prototype = Object.create(KeyframesParent.prototype);
  Object.defineProperty(Keyframes.prototype, 'left', {
    value: ['100px', '200px'],
    enumerable: true,
  });
  const keyframes = new Keyframes();

  const effect = new KeyframeEffect(null, keyframes);

  assert_frame_lists_equal(effect.getKeyframes(), [
    {
      offset: null,
      computedOffset: 0,
      easing: 'linear',
      top: '100px',
      composite: 'auto',
    },
    {
      offset: null,
      computedOffset: 1,
      easing: 'linear',
      top: '200px',
      composite: 'auto',
    },
  ]);
}, 'Only properties defined directly on property-indexed keyframes are read');

test(() => {
  const expectedOrder = ['composite', 'easing', 'offset', 'left', 'marginLeft'];
  const actualOrder = [];
  const kf1 = {};
  for (const {prop, value} of [{ prop: 'marginLeft', value: '10px' },
                               { prop: 'left',       value: '20px' },
                               { prop: 'offset',     value: '0' },
                               { prop: 'easing',     value: 'linear' },
                               { prop: 'composite',  value: 'replace' }]) {
    Object.defineProperty(kf1, prop, {
      enumerable: true,
      get: () => { actualOrder.push(prop); return value; }
    });
  }
  const kf2 = { marginLeft: '10px', left: '20px', offset: 1 };

  new KeyframeEffect(target, [kf1, kf2]);

  assert_array_equals(actualOrder, expectedOrder, 'property access order');
}, 'Properties are read in ascending order by Unicode codepoint');

</script>
