diff options
Diffstat (limited to 'testing/web-platform/tests/scroll-animations/scroll-timelines')
48 files changed, 7124 insertions, 0 deletions
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-ref.html new file mode 100644 index 0000000000..9158715321 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-ref.html @@ -0,0 +1,45 @@ +<!DOCTYPE html> +<title>Reference for Web Animation with scroll timeline tests</title> +<style> + #box { + width: 100px; + height: 100px; + background-color: green; + transform: translate(0, 100px); + opacity: 0.5; + will-change: transform; /* force compositing */ + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + } + + #scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; /* force compositing */ + } + + #contents { + height: 1000px; + width: 100%; + } +</style> + +<div id="box"></div> +<div id="covered"></div> +<div id="scroller"> + <div id="contents"></div> +</div> + +<script> + window.addEventListener('load', function() { + // Move the scroller to halfway. + const scroller = document.getElementById("scroller"); + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.5 * maxScroll; + }); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-animatable-interface.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-animatable-interface.html new file mode 100644 index 0000000000..b04aaf2d33 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-animatable-interface.html @@ -0,0 +1,66 @@ +<html class="reftest-wait"> +<title>Scroll-linked animation with Animatable interface</title> +<link rel="help" href="https://drafts.csswg.org/scroll-animations/"> +<meta name="assert" content="ScrollTimeline should work with animatable +interface"> +<link rel="match" href="animation-ref.html"> + +<script src="/web-animations/testcommon.js"></script> +<script src="/common/reftest-wait.js"></script> + +<style> + #box { + width: 100px; + height: 100px; + background-color: green; + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + } + + #scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; + /* force compositing */ + } + + #contents { + height: 1000px; + width: 100%; + } +</style> + +<div id="box"></div> +<div id="covered"></div> +<div id="scroller"> + <div id="contents"><p>Scrolling Contents</p></div> +</div> + +<script> + const scroller = document.getElementById('scroller'); + const scroll_timeline = new ScrollTimeline({source: scroller}); + const box = document.getElementById('box'); + const animation = box.animate( + [ + { transform: 'translateY(0)', opacity: 1 }, + { transform: 'translateY(200px)', opacity: 0 } + ], { + timeline: scroll_timeline + } + ); + + animation.ready.then(() => { + // Move the scroller to the halfway point. + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.5 * maxScroll; + + waitForAnimationFrames(2).then(_ => { + takeScreenshot(); + }); + }); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-delay-crash.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-delay-crash.html new file mode 100644 index 0000000000..9d821f9e20 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-delay-crash.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<link rel="help" href="https://drafts.csswg.org/web-animations-1/#the-effecttiming-dictionaries"> +<style> +.scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; +} + +.contents { + height: 1000px; + width: 100%; +} +</style> +<div class="scroller"> + <div class="contents"></div> +</div> +<script> + // Test passes if it does not crash. + // Scroll timeline animations are progress-based and not compatible with + // delays specified in milliseconds. + const timeline = new ScrollTimeline(); + const options = { + timeline: timeline, + endDelay: 200 + }; + const keyframes = { opacity: [0, 1]}; + const element = document.querySelector('.contents'); + element.animate(keyframes, options); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-display-none.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-display-none.html new file mode 100644 index 0000000000..a62916833c --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-display-none.html @@ -0,0 +1,75 @@ +<html class="reftest-wait"> +<title>Scroll timeline with Web Animation and transition from display:none to display:block</title> +<link rel="help" href="https://drafts.csswg.org/scroll-animations/"> +<meta name="assert" content="Scroll timeline should properly handle going from display:none to display:block"> +<link rel="match" href="animation-ref.html"> + +<script src="/web-animations/testcommon.js"></script> +<script src="/common/reftest-wait.js"></script> + +<style> + #box { + width: 100px; + height: 100px; + background-color: green; + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + } + + #scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; /* force compositing */ + } + + .removed { + display: none; + } + + #contents { + height: 1000px; + width: 100%; + } +</style> + +<div id="box"></div> +<div id="covered"></div> +<div id="scroller"> + <div id="contents"><p>Scrolling Contents</p></div> +</div> + +<script> + const box = document.getElementById('box'); + const effect = new KeyframeEffect(box, + [ + { transform: 'translateY(0)', opacity: 1 }, + { transform: 'translateY(200px)', opacity: 0 } + ], { + duration: 1000, + } + ); + + const scroller = document.getElementById('scroller'); + scroller.classList.add('removed'); + const timeline = new ScrollTimeline( + { source: scroller, orientation: 'block' }); + const animation = new Animation(effect, timeline); + animation.play(); + + waitForAnimationFrames(2).then(_ => { + scroller.classList.remove('removed'); + animation.ready.then(() => { + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.5 * maxScroll; + + waitForAnimationFrames(2).then(_ => { + takeScreenshot(); + }); + }); + }); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-offsets-crash.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-offsets-crash.html new file mode 100644 index 0000000000..d4d1a55214 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-offsets-crash.html @@ -0,0 +1,32 @@ +<!DOCTYPE html> +<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scrolltimeline-interface"> +<style> +.scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; +} + +.contents { + height: 1000px; + width: 100%; +} +</style> +<div class="scroller"> + <div class="contents"></div> +</div> +<script> + // Test passes if it does not crash. + // Scroll timeline animations are progress-based and not compatible with + // delays specified in milliseconds. + const scroller = document.querySelector('.scroller'); + const animation = new Animation(); + const timeline = animation.timeline; + const duration = timeline.duration; + const options = { + source: scroller, + scrollOffsets: [new CSSMathInvert(duration)] + }; + const scroll_timeline = new ScrollTimeline(options); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden-ref.html new file mode 100644 index 0000000000..c045f1a1c9 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden-ref.html @@ -0,0 +1,45 @@ +<!DOCTYPE html> +<title>Scroll timeline with Web Animation using a scroller with overflow hidden</title> +<style> + #box { + width: 100px; + height: 100px; + background-color: green; + transform: translate(0, 100px); + opacity: 0.5; + will-change: transform; /* force compositing */ + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + } + + #scroller { + overflow: hidden; + height: 100px; + width: 100px; + will-change: transform; /* force compositing */ + } + + #contents { + height: 1000px; + width: 100%; + } +</style> + +<div id="box"></div> +<div id="covered"></div> +<div id="scroller"> + <div id="contents"></div> +</div> + +<script> + window.addEventListener('load', function() { + // Move the scroller to halfway. + const scroller = document.getElementById("scroller"); + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.5 * maxScroll; + }); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden.html new file mode 100644 index 0000000000..bc7611d05a --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden.html @@ -0,0 +1,64 @@ +<html class="reftest-wait"> +<title>Scroll timeline with Web Animation using a scroller with overflow hidden</title> +<link rel="help" href="https://drafts.csswg.org/scroll-animations/"> +<meta name="assert" content="Web animation correctly updates values when using a overflow: hidden on the scroller being used as the source for the ScrollTimeline"> +<link rel="match" href="animation-with-overflow-hidden-ref.html"> + +<script src="/web-animations/testcommon.js"></script> +<script src="/common/reftest-wait.js"></script> + +<style> + #box { + width: 100px; + height: 100px; + background-color: green; + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + } + + #scroller { + overflow: hidden; + height: 100px; + width: 100px; + } + + #contents { + height: 1000px; + width: 100%; + } +</style> + +<div id="box"></div> +<div id="covered"></div> +<div id="scroller"> + <div id="contents"></div> +</div> + +<script> + const box = document.getElementById('box'); + const effect = new KeyframeEffect(box, + [ + {transform: 'translateY(0)', opacity: 1}, + {transform: 'translateY(200px)', opacity: 0} + ] + ); + + const scroller = document.getElementById('scroller'); + const timeline = new ScrollTimeline( + { source: scroller, orientation: 'block' }); + const animation = new Animation(effect, timeline); + animation.play(); + + animation.ready.then(() => { + // Move the scroller to the halfway point. + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.5 * maxScroll; + waitForAnimationFrames(2).then(_ => { + takeScreenshot(); + }); + }); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller-ref.html new file mode 100644 index 0000000000..58435be631 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller-ref.html @@ -0,0 +1,37 @@ +<!DOCTYPE html> +<title>Reference for Scroll timeline with Web Animation using the root scroller</title> +<style> + html { + min-height: 100%; + min-width: 100%; + padding-bottom: 100px; + padding-right: 100px; + } + + #box { + width: 100px; + height: 100px; + background-color: green; + transform: translate(0, 100px); + opacity: 0.5; + will-change: transform; /* force compositing */ + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + } +</style> + +<div id="box"></div> +<div id="covered"><p>Covered Contents</p></div> + +<script> + window.addEventListener('load', function() { + // Move the scroller to halfway. + const scroller = document.scrollingElement; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.5 * maxScroll; + }); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller.html new file mode 100644 index 0000000000..6ba1a22445 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller.html @@ -0,0 +1,60 @@ +<html class="reftest-wait"> +<title>Scroll timeline with Web Animation using the root scroller</title> +<link rel="help" href="https://drafts.csswg.org/scroll-animations/"> +<meta name="assert" content="Web animation correctly updates values when using the root scroller as the source for the ScrollTimeline"> +<link rel="match" href="animation-with-root-scroller-ref.html"> + +<script src="/web-animations/testcommon.js"></script> +<script src="/common/reftest-wait.js"></script> + +<style> + html { + min-height: 100%; + min-width: 100%; + padding-bottom: 100px; + padding-right: 100px; + } + + #box { + width: 100px; + height: 100px; + background-color: green; + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + } +</style> + +<div id="box"></div> +<div id="covered"><p>Covered Contents</p></div> + +<script> + const box = document.getElementById('box'); + const effect = new KeyframeEffect(box, + [ + {transform: 'translateY(0)', opacity: 1}, + {transform: 'translateY(200px)', opacity: 0} + ], { + duration: 1000, + } + ); + + const scroller = document.scrollingElement; + const timeline = new ScrollTimeline( + { source: scroller, orientation: 'block' }); + const animation = new Animation(effect, timeline); + animation.play(); + + animation.ready.then(() => { + // Move the scroller to the halfway point. + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.5 * maxScroll; + + waitForAnimationFrames(2).then(_ => { + takeScreenshot(); + }); + }); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-transform.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-transform.html new file mode 100644 index 0000000000..f741cc634d --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-transform.html @@ -0,0 +1,68 @@ +<html class="reftest-wait"> +<title>Basic use of scroll timeline with Web Animation</title> +<link rel="help" href="https://drafts.csswg.org/scroll-animations/"> +<meta name="assert" content="Should be able to use the scroll timeline to drive the animation timing"> +<link rel="match" href="animation-ref.html"> + +<script src="/web-animations/testcommon.js"></script> +<script src="/common/reftest-wait.js"></script> + +<style> + #box { + width: 100px; + height: 100px; + background-color: green; + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + } + + #scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; /* force compositing */ + } + + #contents { + height: 1000px; + width: 100%; + } +</style> + +<div id="box"></div> +<div id="covered"></div> +<div id="scroller"> + <div id="contents"><p>Scrolling Contents</p></div> +</div> + +<script> + const box = document.getElementById('box'); + const effect = new KeyframeEffect(box, + [ + { transform: 'translateY(0)', opacity: 1}, + { transform: 'translateY(200px)', opacity: 0} + ], { + duration: 1000, + } + ); + + const scroller = document.getElementById('scroller'); + const timeline = new ScrollTimeline( + { source: scroller, orientation: 'block' }); + const animation = new Animation(effect, timeline); + animation.play(); + + animation.ready.then(() => { + // Move the scroller to the halfway point. + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.5 * maxScroll; + + waitForAnimationFrames(2).then(_ => { + takeScreenshot(); + }); + }); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/cancel-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/cancel-animation.html new file mode 100644 index 0000000000..7daf76a7a5 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/cancel-animation.html @@ -0,0 +1,214 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Canceling an animation</title> +<link rel="help" + href="https://drafts.csswg.org/web-animations/#canceling-an-animation-section"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> +.scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; +} + +.contents { + height: 1000px; + width: 100%; +} +</style> +<body> +<script> +'use strict'; + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + animation.cancel(); + + assert_equals(animation.startTime, null, + 'The start time of a canceled animation should be unresolved'); + assert_equals(animation.currentTime, null, + 'The hold time of a canceled animation should be unresolved'); +}, 'Canceling an animation should cause its start time and hold time to be' + + ' unresolved'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + const retPromise = animation.ready.then(() => { + assert_unreached('ready promise was fulfilled'); + }).catch(err => { + assert_equals(err.name, 'AbortError', + 'ready promise is rejected with AbortError'); + }); + + animation.cancel(); + + return retPromise; +}, 'A play-pending ready promise should be rejected when the animation is' + + ' canceled'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + await animation.ready; + + // Make it pause-pending + animation.pause(); + + // We need to store the original ready promise since cancel() will + // replace it + const originalPromise = animation.ready; + animation.cancel(); + + await promise_rejects_dom(t, 'AbortError', originalPromise, + 'Cancel should abort ready promise'); +}, 'A pause-pending ready promise should be rejected when the animation is' + + ' canceled'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + animation.cancel(); + const promiseResult = await animation.ready; + assert_equals(promiseResult, animation); +}, 'When an animation is canceled, it should create a resolved Promise'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + const promise = animation.ready; + animation.cancel(); + assert_not_equals(animation.ready, promise); + promise_rejects_dom(t, 'AbortError', promise, + 'Cancel should abort ready promise'); +}, 'The ready promise should be replaced when the animation is canceled'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + assert_equals(animation.playState, 'idle', + 'The animation should be initially idle'); + + animation.finished.then(t.step_func(() => { + assert_unreached('Finished promise should not resolve'); + }), t.step_func(() => { + assert_unreached('Finished promise should not reject'); + })); + + animation.cancel(); + + return waitForAnimationFrames(3); +}, 'The finished promise should NOT be rejected if the animation is already' + + ' idle'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + assert_equals(animation.playState, 'idle', + 'The animation should be initially idle'); + + animation.oncancel = t.step_func(() => { + assert_unreached('Cancel event should not be fired'); + }); + + animation.cancel(); + + return waitForAnimationFrames(3); +}, 'The cancel event should NOT be fired if the animation is already idle'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + animation.effect.target.remove(); + + const eventWatcher = new EventWatcher(t, animation, 'cancel'); + + await animation.ready; + animation.cancel(); + + await eventWatcher.wait_for('cancel'); + + assert_equals(animation.effect.target.parentNode, null, + 'cancel event should be fired for the animation on an orphaned element'); +}, 'Canceling an animation should fire cancel event on orphaned element'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + await animation.ready; + + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + scroller.scrollTop; + await waitForNextFrame(); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive.'); + animation.cancel(); + assert_equals(animation.startTime, null, + 'The start time of a canceled animation should be unresolved'); + assert_equals(animation.currentTime, null, + 'The current time of a canceled animation should be unresolved'); +}, 'Canceling an animation with inactive timeline should cause its start time' + + ' and hold time to be unresolved'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + await animation.ready; + + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + scroller.scrollTop; + await waitForNextFrame(); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive.'); + + const eventWatcher = new EventWatcher(t, animation, 'cancel'); + animation.cancel(); + const cancelEvent = await eventWatcher.wait_for('cancel'); + + assert_equals(cancelEvent.currentTime, null, + 'event.currentTime should be unresolved when the timeline is inactive.'); + assert_equals(cancelEvent.timelineTime, null, + 'event.timelineTime should be unresolved when the timeline is inactive'); +}, 'oncancel event is fired when the timeline is inactive.'); + +</script> +</body> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor-no-document.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor-no-document.html new file mode 100644 index 0000000000..d2cc590bc7 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor-no-document.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>ScrollTimeline constructor - no document</title> +<link rel="help" href="https://wicg.github.io/scroll-animations/#scrolltimeline-interface"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> + +<script> +'use strict'; + +test(function() { + document.documentElement.remove(); + assert_equals(document.scrollingElement, null); + + const timeline = new ScrollTimeline(); + assert_equals(timeline.source, null); + assert_equals(timeline.currentTime, null); +}, 'The source can be null if the document.scrollingElement does not exist'); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor.html new file mode 100644 index 0000000000..88c6a453ec --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor.html @@ -0,0 +1,95 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>ScrollTimeline constructor</title> + <link rel="help" href="https://wicg.github.io/scroll-animations/#scrolltimeline-interface"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> + +<style> +.scroller { + height: 100px; + width: 100px; + overflow: scroll; +} + +.content { + height: 500px; + width: 500px; +} +</style> + +<div class="scroller"> + <div class="content"></div> +</div> + +<script> +'use strict'; + +function formatOffset(v) { + if (typeof(v) == 'object') + return `${v.constructor.name}(${v.toString()})`; + return `'${v.toString()}'`; +} + +function assert_offsets_equal(a, b) { + assert_equals(formatOffset(a), formatOffset(b)); +} + +// source + +test(t => { + const scroller = document.querySelector('.scroller'); + assert_equals( + new ScrollTimeline({source: scroller}).source, scroller); +}, 'A ScrollTimeline can be created with a source'); + +test(t => { + const div = document.createElement('div'); + assert_equals(new ScrollTimeline({source: div}).source, div); +}, 'A ScrollTimeline can be created with a non-scrolling source'); + +test(t => { + assert_equals(new ScrollTimeline({source: null}).source, null); +}, 'A ScrollTimeline created with a null source should have no source'); + +test(t => { + assert_equals(new ScrollTimeline().source, document.scrollingElement); +}, 'A ScrollTimeline created without a source should use the ' + + 'document.scrollingElement'); + +// axis + +test(t => { + assert_equals(new ScrollTimeline().axis, 'block'); +}, 'A ScrollTimeline created with the default axis should default to ' + + `'block'`); + +const gValidAxisValues = [ + 'block', + 'inline', + 'x', + 'y', +]; + +for (let axis of gValidAxisValues) { + test(function() { + const scrollTimeline = + new ScrollTimeline({axis: axis}); + assert_equals(scrollTimeline.axis, axis); + }, `'${axis}' is a valid axis value`); +} + +test(t => { + let constructorFunc = function() { + new ScrollTimeline({axis: 'nonsense'}) + }; + assert_throws_js(TypeError, constructorFunc); + + // 'auto' for axis was previously in the spec, but was removed. Make + // sure that implementations do not support it. + constructorFunc = function() { + new ScrollTimeline({axis: 'auto'}) + }; + assert_throws_js(TypeError, constructorFunc); +}, 'Creating a ScrollTimeline with an invalid axis value should throw'); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-nan.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-nan.html new file mode 100644 index 0000000000..440b1f413e --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-nan.html @@ -0,0 +1,80 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>ScrollTimeline current time algorithm - NaN cases</title> +<link rel="help" href="https://wicg.github.io/scroll-animations/#current-time-algorithm"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> + +<style> +.scroller { + height: 100px; + width: 100px; + overflow: auto; +} + +.content { + height: 500px; + width: 500px; +} +</style> + +<div id='inlineScroller' class='scroller' style='display: inline;'> + <div class='content'></div> +</div> +<script> +'use strict'; + +test(function() { + const scroller = document.querySelector('#inlineScroller'); + const scrollTimeline = new ScrollTimeline( + { source: scroller, orientation: 'block' }); + + assert_equals(scrollTimeline.currentTime, null); +}, 'currentTime should be null for a display: inline source'); +</script> + +<div id='displayNoneScroller' class='scroller' style='display: none;'> + <div class='content'></div> +</div> +<script> +test(function() { + const scroller = document.querySelector('#displayNoneScroller'); + const scrollTimeline = new ScrollTimeline( + { source: scroller, orientation: 'block' }); + + assert_equals(scrollTimeline.currentTime, null); +}, 'currentTime should be null for a display: none source'); +</script> + +<script> +test(function() { + const scroller = document.createElement('div'); + const content = document.createElement('div'); + + scroller.style.overflow = 'auto'; + scroller.style.height = '100px'; + scroller.style.width = '100px'; + content.style.height = '250px'; + content.style.width = '250px'; + + scroller.appendChild(content); + + const scrollTimeline = new ScrollTimeline( + { source: scroller, orientation: 'block' }); + + assert_equals(scrollTimeline.currentTime, null); +}, 'currentTime should be null for an unattached source'); +</script> + +<div id='notAScroller' class='scroller' style='overflow: visible;'> + <div class='content'></div> +</div> +<script> +test(function() { + const scroller = document.querySelector('#notAScroller'); + const scrollTimeline = new ScrollTimeline( + { source: scroller, orientation: 'block' }); + + assert_equals(scrollTimeline.currentTime, null); +}, 'currentTime should be null when the source is not a scroller'); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-root-scroller.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-root-scroller.html new file mode 100644 index 0000000000..be1d62bec5 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-root-scroller.html @@ -0,0 +1,49 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>ScrollTimeline current time algorithm - root scroller</title> +<link rel="help" href="https://wicg.github.io/scroll-animations/#current-time-algorithm"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="./testcommon.js"></script> + +<style> +html { + /* Ensure the document is scrollable. */ + min-height: 100%; + min-width: 100%; + padding-bottom: 100px; + padding-right: 100px; +} +</style> + +<script> +promise_test(async t => { + const scroller = document.scrollingElement; + // Allow layout to finish, otherwise the scroller isn't set up by the time + // we check the currentTime of the scroll timeline. + await waitForNextFrame(); + + const blockScrollTimeline = new ScrollTimeline( + { source: scroller, axis: 'block' }); + const inlineScrollTimeline = new ScrollTimeline( + { source: scroller, axis: 'inline' }); + + // Wait for new animation frame which allows the timeline to fully initialize + await waitForNextFrame(); + + // Unscrolled, both timelines should read a currentTime of 0. + assert_percents_equal(blockScrollTimeline.currentTime, 0); + assert_percents_equal(inlineScrollTimeline.currentTime, 0); + + // Now do some scrolling and make sure that the ScrollTimelines update. + scroller.scrollTop = 50; + scroller.scrollLeft = 75; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + assert_percents_equal(blockScrollTimeline.currentTime, 50); + assert_percents_equal(inlineScrollTimeline.currentTime, 75); +}, 'currentTime calculates the correct time for a document.scrollingElement source'); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-writing-modes.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-writing-modes.html new file mode 100644 index 0000000000..748cda2f89 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-writing-modes.html @@ -0,0 +1,148 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>ScrollTimeline current time algorithm - interaction with writing modes</title> +<link rel="help" href="https://wicg.github.io/scroll-animations/#current-time-algorithm"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="./testcommon.js"></script> + +<body></body> + +<script> +'use strict'; + +promise_test(async t => { + const scrollerOverrides = new Map([['direction', 'rtl']]); + const scroller = setupScrollTimelineTest(scrollerOverrides); + const verticalScrollRange = scroller.scrollHeight - scroller.clientHeight; + const horizontalScrollRange = scroller.scrollWidth - scroller.clientWidth; + + const blockScrollTimeline = new ScrollTimeline( + {source: scroller, axis: 'block'}); + const inlineScrollTimeline = new ScrollTimeline( + {source: scroller, axis: 'inline'}); + const horizontalScrollTimeline = new ScrollTimeline( + {source: scroller, axis: 'x'}); + const verticalScrollTimeline = new ScrollTimeline( + {source: scroller, axis: 'y'}); + + // Unscrolled, all timelines should read a current time of 0 even though the + // X-axis will have started at the right hand side for rtl. + assert_percents_equal(blockScrollTimeline.currentTime, 0, + 'Unscrolled block timeline'); + assert_percents_equal(inlineScrollTimeline.currentTime, 0, + 'Unscrolled inline timeline'); + assert_percents_equal(horizontalScrollTimeline.currentTime, 0, + 'Unscrolled horizontal timeline'); + assert_percents_equal(verticalScrollTimeline.currentTime, 0, + 'Unscrolled vertical timeline'); + + // The offset in the inline/horizontal direction should be inverted. The + // block/vertical direction should be unaffected. + scroller.scrollTop = 0.1 * verticalScrollRange; + scroller.scrollLeft = -0.8 * horizontalScrollRange; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + assert_percents_equal(blockScrollTimeline.currentTime, 10, + 'Scrolled block timeline'); + assert_percents_equal(inlineScrollTimeline.currentTime, 80, + 'Scrolled inline timeline'); + assert_percents_equal(horizontalScrollTimeline.currentTime, 80, + 'Scrolled horizontal timeline'); + assert_percents_equal(verticalScrollTimeline.currentTime, 10, + 'Scrolled vertical timeline'); +}, 'currentTime handles direction: rtl correctly'); + +promise_test(async t => { + const scrollerOverrides = new Map([['writing-mode', 'vertical-rl']]); + const scroller = setupScrollTimelineTest(scrollerOverrides); + const verticalScrollRange = scroller.scrollHeight - scroller.clientHeight; + const horizontalScrollRange = scroller.scrollWidth - scroller.clientWidth; + + const blockScrollTimeline = new ScrollTimeline( + {source: scroller, axis: 'block'}); + const inlineScrollTimeline = new ScrollTimeline( + {source: scroller, axis: 'inline'}); + const horizontalScrollTimeline = new ScrollTimeline( + {source: scroller, axis: 'x'}); + const verticalScrollTimeline = new ScrollTimeline( + {source: scroller, axis: 'y'}); + + // Unscrolled, all timelines should read a current time of 0 even though the + // X-axis will have started at the right hand side for vertical-rl. + assert_percents_equal(blockScrollTimeline.currentTime, 0, + 'Unscrolled block timeline'); + assert_percents_equal(inlineScrollTimeline.currentTime, 0, + 'Unscrolled inline timeline'); + assert_percents_equal(horizontalScrollTimeline.currentTime, 0, + 'Unscrolled horizontal timeline'); + assert_percents_equal(verticalScrollTimeline.currentTime, 0, + 'Unscrolled vertical timeline'); + + // For vertical-rl, the X-axis starts on the right-hand-side and is the block + // axis. The Y-axis is normal but is the inline axis. For the + // horizontal/vertical cases, horizontal starts on the right-hand-side and + // vertical is normal. + scroller.scrollTop = 0.1 * verticalScrollRange; + scroller.scrollLeft = -0.8 * horizontalScrollRange; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + assert_percents_equal(blockScrollTimeline.currentTime, 80, + 'Scrolled block timeline'); + assert_percents_equal(inlineScrollTimeline.currentTime, 10, + 'Scrolled inline timeline'); + assert_percents_equal(horizontalScrollTimeline.currentTime, 80, + 'Scrolled horizontal timeline'); + assert_percents_equal(verticalScrollTimeline.currentTime, 10, + 'Scrolled vertical timeline'); +}, 'currentTime handles writing-mode: vertical-rl correctly'); + +promise_test(async t => { + const scrollerOverrides = new Map([['writing-mode', 'vertical-lr']]); + const scroller = setupScrollTimelineTest(scrollerOverrides); + const verticalScrollRange = scroller.scrollHeight - scroller.clientHeight; + const horizontalScrollRange = scroller.scrollWidth - scroller.clientWidth; + + const blockScrollTimeline = new ScrollTimeline( + {source: scroller, axis: 'block'}); + const inlineScrollTimeline = new ScrollTimeline( + {source: scroller, axis: 'inline'}); + const horizontalScrollTimeline = new ScrollTimeline( + {source: scroller, axis: 'x'}); + const verticalScrollTimeline = new ScrollTimeline( + {source: scroller, axis: 'y'}); + + // Unscrolled, all timelines should read a current time of 0. + assert_percents_equal(blockScrollTimeline.currentTime, 0, + 'Unscrolled block timeline'); + assert_percents_equal(inlineScrollTimeline.currentTime, 0, + 'Unscrolled inline timeline'); + assert_percents_equal(horizontalScrollTimeline.currentTime, 0, + 'Unscrolled horizontal timeline'); + assert_percents_equal(verticalScrollTimeline.currentTime, 0, + 'Unscrolled vertical timeline'); + + // For vertical-lr, both axes start at their 'normal' positions but the X-axis + // is the block direction and the Y-axis is the inline direction. This does + // not affect horizontal/vertical. + scroller.scrollTop = 0.1 * verticalScrollRange; + scroller.scrollLeft = 0.2 * horizontalScrollRange; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + assert_percents_equal(blockScrollTimeline.currentTime, 20, + 'Scrolled block timeline'); + assert_percents_equal(inlineScrollTimeline.currentTime, 10, + 'Scrolled inline timeline'); + assert_percents_equal(horizontalScrollTimeline.currentTime, 20, + 'Scrolled horizontal timeline'); + assert_percents_equal(verticalScrollTimeline.currentTime, 10, + 'Scrolled vertical timeline'); +}, 'currentTime handles writing-mode: vertical-lr correctly'); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property-ref.html new file mode 100644 index 0000000000..66e29cde65 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property-ref.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<head> + <link rel="help" src="https://github.com/w3c/csswg-drafts/issues/7759"> + <script src="/web-animations/testcommon.js"></script> + <script src="/common/reftest-wait.js"></script> + <style> + html { + overflow: hidden; + } + .spacer { + height: 300vh; + } + .box { + position: fixed; + left: 0; + top: 0; + width: 100px; + height: 100px; + background: black; + border: solid red; + translate: 100px; + will-change: transform; + } + </style> +</head> +<body> + <div class="box"></div> + <div class='spacer'></div> +</body> +<script> + waitForCompositorReady().then(takeScreenshot); +</script> +</html> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property.html new file mode 100644 index 0000000000..d6fdda6752 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property.html @@ -0,0 +1,46 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<head> + <link rel="help" src="https://github.com/w3c/csswg-drafts/issues/7759"> + <link rel="match" href="custom-property-ref.html"> + <script src="/web-animations/testcommon.js"></script> + <script src="/common/reftest-wait.js"></script> + <style> + html { + overflow: hidden; + } + .spacer { + height: 300vh; + } + .box { + position: fixed; + left: 0; + top: 0; + width: 100px; + height: 100px; + background: black; + border: solid red; + animation: move auto linear; + animation-timeline: scroll(); + } + + @keyframes move { + to { + translate: var(--adjustment); + } + } + </style> +</head> +<body> + <div class="box"></div> + <div class='spacer'></div> +</body> +<script> + scroller = document.scrollingElement; + scroller.scrollTop + = scroller.scrollHeight - scroller.clientHeight; + document.documentElement.style.setProperty( + '--adjustment', `100px`); + waitForCompositorReady().then(takeScreenshot); +</script> +</html> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/effect-updateTiming.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/effect-updateTiming.html new file mode 100644 index 0000000000..0c7a546572 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/effect-updateTiming.html @@ -0,0 +1,630 @@ +<!doctype html> +<meta charset=utf-8> +<title>Scroll based animation: AnimationEffect.updateTiming</title> +<!-- Adapted to progressed based scroll animations from "wpt\web-animations\interfaces\AnimationEffect\updateTiming.html" --> +<link rel="help" href="https://drafts.csswg.org/web-animations-1/#dom-animationeffect-updatetiming"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<script src="/web-animations/resources/easing-tests.js"></script> +<script src="/web-animations/resources/timing-tests.js"></script> +<style> + .scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; + } + .contents { + height: 1000px; + width: 100%; + } +</style> +<body> +<div id="log"></div> +<script> +'use strict'; + +// ------------------------------ +// delay +// ------------------------------ + +promise_test(async t => { + const anim = createScrollLinkedAnimationWithTiming(t, {duration: 1000, delay: 200}) + anim.play(); + + assert_equals(anim.effect.getTiming().delay, 200, 'initial delay 200'); + assert_equals(anim.effect.getComputedTiming().delay, 200, + 'getComputedTiming() initially delay 200'); + + anim.effect.updateTiming({ delay: 100 }); + assert_equals(anim.effect.getTiming().delay, 100, 'set delay 100'); + assert_equals(anim.effect.getComputedTiming().delay, 100, + 'getComputedTiming() after set delay 100'); +}, 'Allows setting the delay to a positive number'); + +test(t => { + const anim = createScrollLinkedAnimationWithTiming(t, {duration: 100, delay: -100}) + anim.play(); + anim.effect.updateTiming({ delay: -100 }); + assert_equals(anim.effect.getTiming().delay, -100, 'set delay -100'); + assert_equals(anim.effect.getComputedTiming().delay, -100, + 'getComputedTiming() after set delay -100'); + assert_percents_equal(anim.effect.getComputedTiming().endTime, 0, + 'getComputedTiming().endTime after set delay -100'); +}, 'Allows setting the delay to a negative number'); + +promise_test(async t => { + const anim = createScrollLinkedAnimationWithTiming(t, {duration: 100}) + anim.play(); + await anim.ready; + anim.effect.updateTiming({ delay: 100 }); + assert_equals(anim.effect.getComputedTiming().progress, null); + assert_equals(anim.effect.getComputedTiming().currentIteration, null); +}, 'Allows setting the delay of an animation in progress: positive delay that' + + ' causes the animation to be no longer in-effect'); + +promise_test(async t => { + const anim = + createScrollLinkedAnimationWithTiming(t, { fill: 'both', duration: 100 }); + anim.play(); + await anim.ready; + anim.effect.updateTiming({ delay: -50 }); + assert_equals(anim.effect.getComputedTiming().progress, 0.5); +}, 'Allows setting the delay of an animation in progress: negative delay that' + + ' seeks into the active interval'); + +promise_test(async t => { + const anim = + createScrollLinkedAnimationWithTiming(t, { fill: 'both', duration: 100 }); + anim.play(); + await anim.ready; + anim.effect.updateTiming({ delay: -100 }); + assert_equals(anim.effect.getComputedTiming().progress, 1); + assert_equals(anim.effect.getComputedTiming().currentIteration, 0); +}, 'Allows setting the delay of an animation in progress: large negative delay' + + ' that causes the animation to be finished'); + +for (const invalid of gBadDelayValues) { + test(t => { + const anim = createScrollLinkedAnimationWithTiming(t) + anim.play(); + assert_throws_js(TypeError, () => { + anim.effect.updateTiming({ delay: invalid }); + }); + }, `Throws when setting invalid delay value: ${invalid}`); +} + + +// ------------------------------ +// endDelay +// ------------------------------ + +promise_test(async t => { + const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 }); + anim.play(); + await anim.ready; + anim.effect.updateTiming({ endDelay: 123.45 }); + assert_time_equals_literal(anim.effect.getTiming().endDelay, 123.45, + 'set endDelay 123.45'); + assert_time_equals_literal(anim.effect.getComputedTiming().endDelay, 123.45, + 'getComputedTiming() after set endDelay 123.45'); +}, 'Allows setting the endDelay to a positive number'); + +promise_test(async t => { + const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 }); + anim.play(); + await anim.ready; + anim.effect.updateTiming({ endDelay: -1000 }); + assert_equals(anim.effect.getTiming().endDelay, -1000, 'set endDelay -1000'); + assert_equals(anim.effect.getComputedTiming().endDelay, -1000, + 'getComputedTiming() after set endDelay -1000'); +}, 'Allows setting the endDelay to a negative number'); + +promise_test(async t => { + const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 }); + anim.play(); + await anim.ready; + assert_throws_js(TypeError, () => { + anim.effect.updateTiming({ endDelay: Infinity }); + }); +}, 'Throws when setting the endDelay to infinity'); + +promise_test(async t => { + const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 }); + anim.play(); + await anim.ready; + assert_throws_js(TypeError, () => { + anim.effect.updateTiming({ endDelay: -Infinity }); + }); +}, 'Throws when setting the endDelay to negative infinity'); + + +// ------------------------------ +// fill +// ------------------------------ + +for (const fill of ['none', 'forwards', 'backwards', 'both']) { + test(t => { + const anim = createScrollLinkedAnimationWithTiming(t, { duration: 100 }) + anim.play(); + anim.effect.updateTiming({ fill }); + assert_equals(anim.effect.getTiming().fill, fill, 'set fill ' + fill); + assert_equals(anim.effect.getComputedTiming().fill, fill, + 'getComputedTiming() after set fill ' + fill); + }, `Allows setting the fill to '${fill}'`); +} + + +// ------------------------------ +// iterationStart +// ------------------------------ + +promise_test(async t => { + const anim = createScrollLinkedAnimationWithTiming(t, { iterationStart: 0.2, + iterations: 1, + fill: 'both', + duration: 100, + delay: 1 }) + anim.play(); + await anim.ready; + anim.effect.updateTiming({ iterationStart: 2.5 }); + assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.5); + assert_equals(anim.effect.getComputedTiming().currentIteration, 2); +}, 'Allows setting the iterationStart of an animation in progress:' + + ' backwards-filling'); + +promise_test(async t => { + const anim = createScrollLinkedAnimationWithTiming(t, { iterationStart: 0.2, + iterations: 1, + fill: 'both', + duration: 100, + delay: 0 }) + anim.play(); + await anim.ready; + anim.effect.updateTiming({ iterationStart: 2.5 }); + assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.5); + assert_equals(anim.effect.getComputedTiming().currentIteration, 2); +}, 'Allows setting the iterationStart of an animation in progress:' + + ' active phase'); + +promise_test(async t => { + const anim = createScrollLinkedAnimationWithTiming(t, { iterationStart: 0.3, + iterations: 1, + fill: 'both', + duration: 200, + delay: 0 }) + anim.play(); + await anim.ready; + assert_percents_equal(anim.currentTime, 0); + assert_percents_equal(anim.effect.getComputedTiming().localTime, 0, + "localTime"); + assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.3); + assert_equals(anim.effect.getComputedTiming().currentIteration, 0); + + anim.finish(); + assert_percents_equal(anim.currentTime, 100); + assert_percents_equal(anim.effect.getComputedTiming().localTime, 100, + "localTime"); + assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.3); + assert_equals(anim.effect.getComputedTiming().currentIteration, 1); + + anim.effect.updateTiming({ iterationStart: 2.5 }); + assert_percents_equal(anim.currentTime, 100); + assert_percents_equal(anim.effect.getComputedTiming().localTime, 100, + "localTime"); + assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.5); + assert_equals(anim.effect.getComputedTiming().currentIteration, 3); +}, 'Allows setting the iterationStart of an animation in progress:' + + ' forwards-filling'); + +for (const invalid of gBadIterationStartValues) { + test(t => { + const anim = createScrollLinkedAnimationWithTiming(t) + anim.play(); + assert_throws_js(TypeError, () => { + anim.effect.updateTiming({ iterationStart: invalid }); + }, `setting ${invalid}`); + }, `Throws when setting invalid iterationStart value: ${invalid}`); +} + +// ------------------------------ +// iterations +// ------------------------------ + +test(t => { + const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 }); + anim.play(); + anim.effect.updateTiming({ iterations: 2 }); + assert_equals(anim.effect.getTiming().iterations, 2, 'set duration 2'); + assert_equals(anim.effect.getComputedTiming().iterations, 2, + 'getComputedTiming() after set iterations 2'); +}, 'Allows setting iterations to a double value'); + +test(t => { + const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 }); + anim.play(); + assert_throws_js(TypeError, () => { + anim.effect.updateTiming({ iterations: Infinity }); + }, "test"); +}, `Throws when setting iterations to Infinity`); + + +// progress based animations behave a bit differently than time based animations +// when changing iterations. +test(t => { + const anim = + createScrollLinkedAnimationWithTiming( + t, { duration: 100000, fill: 'both' }); + anim.play(); + anim.finish(); + + assert_equals(anim.effect.getComputedTiming().progress, 1, + 'progress when animation is finished'); + assert_percents_equal(anim.effect.getComputedTiming().duration, 100, + 'duration when animation is finished'); + assert_equals(anim.effect.getComputedTiming().currentIteration, 0, + 'current iteration when animation is finished'); + + anim.effect.updateTiming({ iterations: 2 }); + + assert_equals(anim.effect.getComputedTiming().progress, 1, + 'progress after adding an iteration'); + assert_percents_equal(anim.effect.getComputedTiming().duration, 50, + 'duration after adding an iteration'); + assert_equals(anim.effect.getComputedTiming().currentIteration, 1, + 'current iteration after adding an iteration'); + + anim.effect.updateTiming({ iterations: 4 }); + + assert_equals(anim.effect.getComputedTiming().progress, 1, + 'progress after setting iterations to 4'); + assert_percents_equal(anim.effect.getComputedTiming().duration, 25, + 'duration after setting iterations to 4'); + assert_equals(anim.effect.getComputedTiming().currentIteration, 3, + 'current iteration after setting iterations to 4'); + + anim.effect.updateTiming({ iterations: 0 }); + + assert_equals(anim.effect.getComputedTiming().progress, 0, + 'progress after setting iterations to zero'); + assert_percents_equal(anim.effect.getComputedTiming().duration, 0, + 'duration after setting iterations to zero'); + assert_equals(anim.effect.getComputedTiming().currentIteration, 0, + 'current iteration after setting iterations to zero'); +}, 'Allows setting the iterations of an animation in progress'); + +// Added test for checking duration "auto" +test(t => { + const anim = createScrollLinkedAnimationWithTiming(t, { fill: 'both' }); + anim.play(); + anim.finish(); + + assert_equals(anim.effect.getComputedTiming().progress, 1, + 'progress when animation is finished'); + assert_percents_equal(anim.effect.getComputedTiming().duration, 100, + 'duration when animation is finished'); + assert_equals(anim.effect.getComputedTiming().currentIteration, 0, + 'current iteration when animation is finished'); + + anim.effect.updateTiming({ iterations: 2 }); + + assert_equals(anim.effect.getComputedTiming().progress, 1, + 'progress after adding an iteration'); + assert_percents_equal(anim.effect.getComputedTiming().duration, 50, + 'duration after adding an iteration'); + assert_equals(anim.effect.getComputedTiming().currentIteration, 1, + 'current iteration after adding an iteration'); + + anim.effect.updateTiming({ iterations: 4 }); + + assert_equals(anim.effect.getComputedTiming().progress, 1, + 'progress after setting iterations to 4'); + assert_percents_equal(anim.effect.getComputedTiming().duration, 25, + 'duration after setting iterations to 4'); + assert_equals(anim.effect.getComputedTiming().currentIteration, 3, + 'current iteration after setting iterations to 4'); + + anim.effect.updateTiming({ iterations: 0 }); + + assert_equals(anim.effect.getComputedTiming().progress, 0, + 'progress after setting iterations to zero'); + assert_percents_equal(anim.effect.getComputedTiming().duration, 0, + 'duration after setting iterations to zero'); + assert_equals(anim.effect.getComputedTiming().currentIteration, 0, + 'current iteration after setting iterations to zero'); +}, 'Allows setting the iterations of an animation in progress with duration ' + + '"auto"'); + + +// ------------------------------ +// duration +// ------------------------------ +// adapted for progress based animations +const gGoodDurationValuesForProgressBased = [ + // until duration returns a CSSNumberish which can handle percentages, 100% + // will be represented as 100 + { specified: 123.45, computed: 100 }, + { specified: 'auto', computed: 100 }, +]; + +for (const duration of gGoodDurationValuesForProgressBased) { + test(t => { + const anim = createScrollLinkedAnimationWithTiming(t, 2000); + anim.play(); + anim.effect.updateTiming({ duration: duration.specified }); + if (typeof duration.specified === 'number') { + assert_time_equals_literal(anim.effect.getTiming().duration, + duration.specified, + 'Updates specified duration'); + } else { + assert_equals(anim.effect.getTiming().duration, duration.specified, + 'Updates specified duration'); + } + assert_percents_equal(anim.effect.getComputedTiming().duration, + duration.computed, + 'Updates computed duration'); + }, `Allows setting the duration to ${duration.specified}`); +} + +// adapted for progress based animations +const gBadDurationValuesForProgressBased = [ + -1, NaN, Infinity, -Infinity, 'abc', '100' +]; + +for (const invalid of gBadDurationValuesForProgressBased) { + test(t => { + assert_throws_js(TypeError, () => { + const anim = createScrollLinkedAnimationWithTiming(t, { duration: invalid }) + anim.play(); + }); + }, 'Throws when setting invalid duration: ' + + (typeof invalid === 'string' ? `"${invalid}"` : invalid)); +} + +test(t => { + const anim = + createScrollLinkedAnimationWithTiming( + t, { duration: 100000, fill: 'both' }); + anim.play(); + anim.finish(); + assert_equals(anim.effect.getComputedTiming().progress, 1, + 'progress when animation is finished'); + anim.effect.updateTiming({ duration: anim.effect.getTiming().duration * 2 }); + assert_time_equals_literal(anim.effect.getComputedTiming().progress, 1, + 'progress after doubling the duration'); + anim.effect.updateTiming({ duration: 0 }); + assert_equals(anim.effect.getComputedTiming().progress, 1, + 'progress after setting duration to zero'); + anim.effect.updateTiming({ duration: 'auto' }); + assert_equals(anim.effect.getComputedTiming().progress, 1, + 'progress after setting duration to \'auto\''); +}, 'Allows setting the duration of an animation in progress'); + +promise_test(t => { + const anim = + createScrollLinkedAnimationWithTiming( + t, { duration: 100000, fill: 'both' }); + anim.play(); + return anim.ready.then(() => { + const originalStartTime = anim.startTime; + const originalCurrentTime = anim.currentTime; + assert_percents_equal( + anim.effect.getComputedTiming().duration, + 100, + 'Initial duration should be as set on KeyframeEffect' + ); + + anim.effect.updateTiming({ duration: 200000 }); + assert_percents_equal( + anim.effect.getComputedTiming().duration, + 100, + 'Effect duration should remain at 100% for progress based animations' + ); + assert_percents_equal(anim.startTime, originalStartTime, + 'startTime should be unaffected by changing effect ' + + 'duration'); + + assert_percents_equal(anim.currentTime, originalCurrentTime, + 'currentTime should be unaffected by changing ' + + 'effect duration'); + }); +}, 'Allows setting the duration of an animation in progress such that the' + + ' the start and current time do not change'); + + +// ------------------------------ +// direction +// ------------------------------ + +test(t => { + const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 }); + anim.play(); + + const directions = ['normal', 'reverse', 'alternate', 'alternate-reverse']; + for (const direction of directions) { + anim.effect.updateTiming({ direction: direction }); + assert_equals(anim.effect.getTiming().direction, direction, + `set direction to ${direction}`); + } +}, 'Allows setting the direction to each of the possible keywords'); + +promise_test(async t => { + const anim = + createScrollLinkedAnimationWithTiming( + t, { duration: 10000, direction: 'normal' }); + + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + anim.play(); + await anim.ready; + scroller.scrollTop = maxScroll * 0.07 + await waitForNextFrame(); + + assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.07, + 'progress before updating direction'); + + anim.effect.updateTiming({ direction: 'reverse' }); + + assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.93, + 'progress after updating direction'); +}, 'Allows setting the direction of an animation in progress from \'normal\' ' + + 'to \'reverse\''); + +promise_test(async t => { + const anim = + createScrollLinkedAnimationWithTiming( + t, { duration: 10000, direction: 'normal' }); + anim.play(); + await anim.ready; + assert_equals(anim.effect.getComputedTiming().progress, 0, + 'progress before updating direction'); + + anim.effect.updateTiming({ direction: 'reverse' }); + + assert_equals(anim.effect.getComputedTiming().progress, 1, + 'progress after updating direction'); +}, 'Allows setting the direction of an animation in progress from \'normal\' to' + + ' \'reverse\' while at start of active interval'); + +promise_test(async t => { + const anim = createScrollLinkedAnimationWithTiming(t, { fill: 'backwards', + duration: 10000, + delay: 10000, + direction: 'normal' }); + anim.play(); + await anim.ready; + assert_equals(anim.effect.getComputedTiming().progress, 0, + 'progress before updating direction'); + + anim.effect.updateTiming({ direction: 'reverse' }); + + assert_equals(anim.effect.getComputedTiming().progress, 1, + 'progress after updating direction'); +}, 'Allows setting the direction of an animation in progress from \'normal\' to' + + ' \'reverse\' while filling backwards'); + +promise_test(async t => { + const anim = createScrollLinkedAnimationWithTiming(t, { iterations: 2, + duration: 10000, + direction: 'normal' }); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + anim.play(); + await anim.ready; + scroller.scrollTop = maxScroll * 0.17 // 34% through first iteration + await waitForNextFrame(); + + assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.34, + 'progress before updating direction'); + + anim.effect.updateTiming({ direction: 'alternate' }); + + assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.34, + 'progress after updating direction'); +}, 'Allows setting the direction of an animation in progress from \'normal\' to' + + ' \'alternate\''); + +promise_test(async t => { + const anim = createScrollLinkedAnimationWithTiming(t, { iterations: 2, + duration: 10000, + direction: 'alternate' }); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + anim.play(); + await anim.ready; + scroller.scrollTop = maxScroll * 0.17 + await waitForNextFrame(); + // anim.currentTime = 17000; // 34% through first iteration + assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.34, + 'progress before updating direction'); + + anim.effect.updateTiming({ direction: 'alternate-reverse' }); + + assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.66, + 'progress after updating direction'); +}, 'Allows setting the direction of an animation in progress from \'alternate\'' + + ' to \'alternate-reverse\''); + + +// ------------------------------ +// easing +// ------------------------------ + +async function assert_progress(animation, scrollPercent, easingFunction) { + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = maxScroll * scrollPercent + await waitForNextFrame(); + assert_approx_equals(animation.effect.getComputedTiming().progress, + easingFunction(scrollPercent), + 0.01, + 'The progress of the animation should be approximately' + + ` ${easingFunction(scrollPercent)} at ${scrollPercent}%`); +} + +for (const options of gEasingTests) { + promise_test(async t => { + const anim = createScrollLinkedAnimationWithTiming(t, { duration: 100000, + fill: 'forwards' }); + anim.play(); + anim.effect.updateTiming({ easing: options.easing }); + assert_equals(anim.effect.getTiming().easing, + options.serialization || options.easing); + + const easing = options.easingFunction; + await assert_progress(anim, 0, easing); + await assert_progress(anim, 0.25, easing); + await assert_progress(anim, 0.5, easing); + await assert_progress(anim, 0.75, easing); + await assert_progress(anim, 1, easing); + }, `Allows setting the easing to a ${options.desc}`); +} + +for (const easing of gRoundtripEasings) { + test(t => { + const anim = createScrollLinkedAnimationWithTiming(t); + anim.play(); + anim.effect.updateTiming({ easing: easing }); + assert_equals(anim.effect.getTiming().easing, easing); + }, `Updates the specified value when setting the easing to '${easing}'`); +} + +// Because of the delay being so large, this progress based animation is always +// in the finished state with progress 1. Included here because it is in the +// original test file for time based animations. +promise_test(async t => { + const delay = 1000000; + + const anim = createScrollLinkedAnimationWithTiming(t, + { duration: 1000000, fill: 'both', delay: delay, easing: 'steps(2, start)' }); + + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + anim.play(); + await anim.ready; + + anim.effect.updateTiming({ easing: 'steps(2, end)' }); + assert_equals(anim.effect.getComputedTiming().progress, 0, + 'easing replace to steps(2, end) at before phase'); + + scroller.scrollTop = maxScroll * 0.875 + await waitForNextFrame(); + + assert_equals(anim.effect.getComputedTiming().progress, 0.5, + 'change currentTime to active phase'); + + anim.effect.updateTiming({ easing: 'steps(2, start)' }); + assert_equals(anim.effect.getComputedTiming().progress, 1, + 'easing replace to steps(2, start) at active phase'); + + scroller.scrollTop = maxScroll * 1.25 + await waitForNextFrame(); + + anim.effect.updateTiming({ easing: 'steps(2, end)' }); + assert_equals(anim.effect.getComputedTiming().progress, 1, + 'easing replace to steps(2, end) again at after phase'); +}, 'Allows setting the easing of an animation in progress'); +</script> +</body> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/finish-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/finish-animation.html new file mode 100644 index 0000000000..3faff63dc9 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/finish-animation.html @@ -0,0 +1,393 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Finishing an animation</title> +<link rel="help" + href="https://drafts.csswg.org/web-animations/#finishing-an-animation-section"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> +.scroller { + overflow: auto; + height: 200px; + width: 100px; + will-change: transform; +} + +.contents { + height: 1000px; + width: 100%; +} +</style> +<body> +<div id="log"></div> +<script> +'use strict'; + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.playbackRate = 0; + + assert_throws_dom('InvalidStateError', () => { + animation.finish(); + }); +}, 'Finishing an animation with a zero playback rate throws'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.finish(); + + assert_percents_equal(animation.currentTime, 100, + 'After finishing, the currentTime should be set to the end of the' + + ' active duration'); +}, 'Finishing an animation seeks to the end time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + // 1% past effect end + animation.currentTime = CSSNumericValue.parse("101%"); + animation.finish(); + + assert_percents_equal(animation.currentTime, 100, + 'After finishing, the currentTime should be set back to the end of the' + + ' active duration'); +}, 'Finishing an animation with a current time past the effect end jumps' + + ' back to the end'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + animation.play(); + scroller.scrollTop = maxScroll; + await animation.finished; + + animation.playbackRate = -1; + animation.finish(); + + assert_percents_equal(animation.currentTime, 0, + 'After finishing a reversed animation the ' + + 'currentTime should be set to zero'); +}, 'Finishing a reversed animation jumps to zero time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.currentTime = CSSNumericValue.parse("100%"); + await animation.finished; + + animation.playbackRate = -1; + animation.currentTime = CSSNumericValue.parse("-1000%"); + animation.finish(); + + assert_percents_equal(animation.currentTime, 0, + 'After finishing a reversed animation the ' + + 'currentTime should be set back to zero'); +}, 'Finishing a reversed animation with a current time less than zero' + + ' makes it jump back to zero'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.playbackRate = 0.5; + animation.finish(); + + assert_false(animation.pending); + assert_equals(animation.playState, 'finished', + 'The play state of a play-pending animation should become ' + + '"finished"'); + assert_percents_equal(animation.startTime, + animation.timeline.currentTime.value - 100 / 0.5, + 'The start time of a play-pending animation should ' + + 'be set'); +}, 'Finishing an animation while play-pending resolves the pending' + + ' task immediately'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + await runAndWaitForFrameUpdate(() => { + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + }); + animation.play(); + animation.finish(); + + await animation.finished; + + assert_true(animation.pending); + assert_equals(animation.playState, 'finished', + 'The play state of a play-pending animation should become ' + + '"finished"'); + assert_percents_equal(animation.currentTime, 100, + 'The current time of a play-pending animation should ' + + 'be set to the end of the active duration'); + assert_equals(animation.startTime, null, + 'The start time of a finished play-pending animation should ' + + 'be unresolved'); +}, 'Finishing an animation attached to inactive timeline while play-pending ' + + 'doesn\'t resolves the pending task'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + let resolvedFinished = false; + animation.finished.then(() => { + resolvedFinished = true; + }); + + await animation.ready; + + animation.finish(); + await Promise.resolve(); + + assert_true(resolvedFinished, 'finished promise should be resolved'); +}, 'Finishing an animation resolves the finished promise synchronously'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + const promise = animation.ready; + let readyResolved = false; + + animation.finish(); + animation.ready.then(() => { readyResolved = true; }); + + const promiseResult = await animation.finished; + await animation.ready; + + assert_equals(promiseResult, animation); + assert_equals(animation.ready, promise); + assert_true(readyResolved); +}, 'A pending ready promise is resolved and not replaced when the animation' + + ' is finished'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.effect.target.remove(); + + const eventWatcher = new EventWatcher(t, animation, 'finish'); + + await animation.ready; + animation.finish(); + + await eventWatcher.wait_for('finish'); + assert_equals(animation.effect.target.parentNode, null, + 'finish event should be fired for the animation on an orphaned element'); +}, 'Finishing an animation fires finish event on orphaned element'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + await animation.ready; + + const originalFinishPromise = animation.finished; + + animation.cancel(); + assert_equals(animation.startTime, null); + assert_equals(animation.currentTime, null); + + const resolvedFinishPromise = animation.finished; + assert_not_equals(originalFinishPromise, resolvedFinishPromise, + 'Canceling an animation should create a new finished promise'); + + animation.finish(); + assert_equals(animation.playState, 'finished', + 'The play state of a canceled animation should become ' + + '"finished"'); + assert_percents_equal(animation.startTime, + animation.timeline.currentTime.value - 100, + 'The start time of a finished animation should be set'); + assert_percents_equal(animation.currentTime, 100, + 'Hold time should be set to end boundary of the ' + + 'animation'); + +}, 'Finishing a canceled animation sets the current and start times'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.25 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + const eventWatcher = new EventWatcher(t, animation, 'finish'); + animation.finish(); + await animation.finished; + const finishEvent = await eventWatcher.wait_for('finish'); + assert_equals(animation.playState, 'finished', + 'Animation is finished.'); + assert_percents_equal(animation.currentTime, 100, + 'The current time is the end of the active duration in finished state.'); + assert_percents_equal(animation.startTime, -75, + 'The start time is calculated to match the current time.'); + assert_percents_equal(finishEvent.currentTime, 100, + 'event.currentTime is the animation current time.'); + assert_percents_equal(finishEvent.timelineTime, 25, + 'event.timelineTime is timeline.currentTime'); +}, 'Finishing idle animation produces correct state and fires finish event.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + await waitForNextFrame(); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive.'); + animation.finish(); + assert_equals(animation.playState, 'paused', 'Animation is paused.'); +}, 'Finishing idle animation attached to inactive timeline pauses the ' + + 'animation.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.25 * maxScroll; + animation.play(); + await animation.ready; + + const eventWatcher = new EventWatcher(t, animation, 'finish'); + animation.finish(); + await animation.finished; + const finishEvent = await eventWatcher.wait_for('finish'); + assert_equals(animation.playState, 'finished', + 'Animation is finished.'); + assert_percents_equal(animation.currentTime, 100, + 'The current time is the end of active duration in finished state.'); + assert_percents_equal(animation.startTime, -75, + 'The start time is calculated to match animation current time.'); + assert_percents_equal(finishEvent.currentTime, 100, + 'event.currentTime is the animation current time.'); + assert_percents_equal(finishEvent.timelineTime, 25, + 'event.timelineTime is timeline.currentTime'); +}, 'Finishing running animation produces correct state and fires finish event.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + animation.play(); + await animation.ready; + + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + scroller.scrollTop; + await waitForNextFrame(); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive.'); + assert_equals(animation.playState, 'running', + 'Sanity check the animation is running.'); + + animation.finish(); + assert_equals(animation.playState, 'paused', 'Animation is paused.'); +}, 'Finishing running animation attached to inactive timeline pauses the ' + + 'animation.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.pause(); + await animation.ready; + + animation.finish(); + + assert_equals(animation.playState, 'finished', + 'The play state of a paused animation should become ' + + '"finished"'); + assert_percents_equal(animation.startTime, + animation.timeline.currentTime.value - 100, + 'The start time of a paused animation should be set'); +}, 'Finishing a paused animation resolves the start time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + // Update playbackRate so we can test that the calculated startTime + // respects it + animation.playbackRate = 2; + animation.pause(); + // While animation is still pause-pending call finish() + animation.finish(); + + assert_false(animation.pending); + assert_equals(animation.playState, 'finished', + 'The play state of a pause-pending animation should become ' + + '"finished"'); + assert_percents_equal(animation.startTime, + animation.timeline.currentTime.value - 100 / 2, + 'The start time of a pause-pending animation should ' + + 'be set'); +}, 'Finishing a pause-pending animation resolves the pending task' + + ' immediately and update the start time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.playbackRate = -2; + animation.pause(); + animation.finish(); + + assert_false(animation.pending); + assert_equals(animation.playState, 'finished', + 'The play state of a pause-pending animation should become ' + + '"finished"'); + assert_percents_equal(animation.startTime, + animation.timeline.currentTime, + 'The start time of a pause-pending animation should ' + + 'be set'); +}, 'Finishing a pause-pending animation with negative playback rate' + + ' resolves the pending task immediately'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + await animation.ready; + + animation.pause(); + animation.play(); + // We are now in the unusual situation of being play-pending whilst having + // a resolved start time. Check that finish() still triggers a transition + // to the finished state immediately. + animation.finish(); + + assert_equals(animation.playState, 'finished', + 'After aborting a pause then finishing an animation its play ' + + 'state should become "finished" immediately'); +}, 'Finishing an animation during an aborted pause makes it finished' + + ' immediately'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + await animation.ready; + + animation.updatePlaybackRate(2); + assert_true(animation.pending); + + animation.finish(); + assert_false(animation.pending); + assert_equals(animation.playbackRate, 2); + assert_percents_equal(animation.currentTime, 100); +}, 'A pending playback rate should be applied immediately when an animation' + + ' is finished'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + await animation.ready; + + animation.updatePlaybackRate(0); + + assert_throws_dom('InvalidStateError', () => { + animation.finish(); + }); +}, 'An exception should be thrown if the effective playback rate is zero'); +</script> +</body> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/idlharness.window.js b/testing/web-platform/tests/scroll-animations/scroll-timelines/idlharness.window.js new file mode 100644 index 0000000000..90157580ce --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/idlharness.window.js @@ -0,0 +1,16 @@ +// META: script=/resources/WebIDLParser.js +// META: script=/resources/idlharness.js + +'use strict'; + +idl_test( + ['scroll-animations'], + // The css-pseudo dependency shouldn't be necessary, but is: + // https://github.com/web-platform-tests/wpt/issues/12574 + ['web-animations', 'css-pseudo', 'dom'], + idl_array => { + idl_array.add_objects({ + ScrollTimeline: ['new ScrollTimeline()'], + }); + } +); diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/intrinsic-iteration-duration.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/intrinsic-iteration-duration.tentative.html new file mode 100644 index 0000000000..4bcea1adba --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/intrinsic-iteration-duration.tentative.html @@ -0,0 +1,78 @@ +<!doctype html> +<meta charset=utf-8> +<title>Scroll based animation: AnimationEffect.getComputedTiming</title> +<link rel="help" href="https://www.w3.org/TR/web-animations-2/#dom-animationeffect-getcomputedtiming"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> + .scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; + } + .contents { + height: 1000px; + width: 100%; + } +</style> +<body> +<div id="log"></div> +<script type="text/javascript"> + +//------------------------------------ +// Time-based duration +//------------------------------------ + +test(t => { + const anim = createScrollLinkedAnimationWithTiming(t, {duration: 1000 }); + assert_equals(anim.effect.getTiming().duration, 1000); + assert_percents_equal(anim.effect.getComputedTiming().duration, 100); +}, 'Computed duration in percent even when specified in ms'); + +test(t => { + const anim = createScrollLinkedAnimationWithTiming(t, { duration: 1000 }); + anim.rangeStart = { offset: CSS.percent(20) }; + anim.rangeEnd = { offset: CSS.percent(80) }; + assert_equals(anim.effect.getTiming().duration, 1000); + assert_percents_equal(anim.effect.getComputedTiming().duration, 60); +}, 'Time-based duration normalized to fill animation range.'); + +test(t => { + const anim = + createScrollLinkedAnimationWithTiming( + t, {duration: 700, delay: 100, endDelay: 200 }); + assert_equals(anim.effect.getTiming().duration, 700); + assert_percents_equal(anim.effect.getComputedTiming().duration, 70); +}, 'Time-based duration normalized to preserve proportional delays.'); + +//------------------------------------------------- +// Duration 'auto' = Intrinsic iteration duration +//------------------------------------------------- + +test(t => { + const anim = createScrollLinkedAnimationWithTiming(t, {}); + assert_equals(anim.effect.getTiming().duration, 'auto'); + assert_percents_equal(anim.effect.getComputedTiming().duration, 100); +}, 'Intrinsic iteration duration fills timeline.'); + +test(t => { + const anim = createScrollLinkedAnimationWithTiming(t, {}); + anim.rangeStart = { offset: CSS.percent(30) }; + anim.rangeEnd = { offset: CSS.percent(90) }; + assert_equals(anim.effect.getTiming().duration, 'auto'); + assert_percents_equal(anim.effect.getComputedTiming().duration, 60); +}, 'Intrinsic iteration duration accounts for animation range.'); + +test(t => { + const anim = + createScrollLinkedAnimationWithTiming( + t, { iterations: 4 }); + assert_equals(anim.effect.getTiming().duration, 'auto'); + assert_percents_equal(anim.effect.getComputedTiming().duration, 25); +}, 'Intrinsic iteration duration accounts for number of iterations'); + +</script> +</body> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/layout-changes-on-percentage-based-timeline.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/layout-changes-on-percentage-based-timeline.html new file mode 100644 index 0000000000..c5a46a501e --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/layout-changes-on-percentage-based-timeline.html @@ -0,0 +1,84 @@ +<html class="reftest-wait"> +<title>Layout changes on percentage-based scroll timeline</title> +<link rel="help" href="https://drafts.csswg.org/scroll-animations/"> +<meta name="assert" content="Scroll timeline should properly handle +layout changes on percentage-based scroll offset"> +<link rel="match" href="animation-ref.html"> + +<script src="/web-animations/testcommon.js"></script> +<script src="/common/reftest-wait.js"></script> + +<style> + #box { + width: 100px; + height: 100px; + background-color: green; + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + color: red; + } + + #scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; + } + + #contents { + height: 500px; + width: 100%; + } + + #spacer { + height: 80px; + } + + .invisible { + display: none; + } +</style> + +<div id="box"></div> +<div id="covered">Scrolling Test</div> +<div id="scroller"> + <div id="contents"></div> + <div id="spacer" class="invisible"></div> +</div> + +<script> + const box = document.getElementById('box'); + const effect = new KeyframeEffect(box, + [ + { transform: 'translateY(0)', opacity: 1 }, + { transform: 'translateY(200px)', opacity: 0 } + ] + ); + + const scroller = document.getElementById('scroller'); + const timeline = new ScrollTimeline({ + source: scroller + }); + const animation = new Animation(effect, timeline); + animation.play(); + animation.ready.then(_ => { + // Moves the scroller to the end point (240px). + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = maxScroll * 0.6; + + // Makes sure that the animation runs on compositor with current scroll offset + waitForAnimationFrames(2).then(_ => { + // Adds 80px to scroll height which pushes scroll progress back to 50%. + const spacer = document.getElementById('spacer'); + spacer.classList.remove('invisible'); + // Makes sure that the change is propagated to the compositor. + waitForAnimationFrames(2).then(_ => { + takeScreenshot(); + }); + }); + }); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/null-scroll-source-crash.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/null-scroll-source-crash.html new file mode 100644 index 0000000000..53ad0d9285 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/null-scroll-source-crash.html @@ -0,0 +1,24 @@ +<link rel="help" href="https://bugs.chromium.org/p/chromium/issues/detail?id=1088319"> +<meta name="assert" content="Playing animation with null scroll source should not crash."> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<style> + html { + overflow: scroll; + } + + body { + overflow: scroll; + } +</style> +<div id="box"></div> +<script> + test(() => { + const effect = new KeyframeEffect(box, []); + const timeline = new ScrollTimeline(); + const animation = new Animation(effect, timeline); + assert_equals(document.scrollingElement, null, + "This test relies on scrolling element being nil"); + animation.play(); + }, "Playing animation with null scroll source should not crash"); +</script>
\ No newline at end of file diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/pause-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/pause-animation.html new file mode 100644 index 0000000000..1f9641e2f8 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/pause-animation.html @@ -0,0 +1,178 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Pausing an animation</title> +<link rel="help" + href="https://drafts.csswg.org/web-animations/#pausing-an-animation-section"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> +.scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; +} + +.contents { + height: 1000px; + width: 100%; +} +</style> +<body> +<script> +'use strict'; + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + await animation.ready; + + const startTimeBeforePausing = animation.startTime; + + animation.pause(); + assert_percents_equal(animation.startTime, startTimeBeforePausing, + 'The start time does not change when pausing-pending'); + + await animation.ready; + + assert_equals(animation.startTime, null, + 'The start time is unresolved when paused'); +}, 'Pausing clears the start time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + const promise = animation.ready; + animation.pause(); + + const promiseResult = await promise; + + assert_equals(promiseResult, animation); + assert_equals(animation.ready, promise); + assert_false(animation.pending, 'No longer pause-pending'); +}, 'A pending ready promise should be resolved and not replaced when the' + + ' animation is paused'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + // Let animation start roughly half-way through + animation.currentTime = CSSNumericValue.parse("50%"); + await animation.ready; + + // Go pause-pending and also set a pending playback rate + animation.pause(); + animation.updatePlaybackRate(0.5); + + await animation.ready; + // If the current time was updated using the new playback rate it will jump + // back to 25% but if we correctly used the old playback rate the current time + // will be > 50%. + assert_percents_equal(animation.currentTime, 50); +}, 'A pause-pending animation maintains the current time when applying a' + + ' pending playback rate'); + +promise_test(async t => { + // This test does not cover a specific step in the algorithm but serves as a + // high-level sanity check that pausing does, in fact, freeze the current + // time. + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + animation.play(); + await animation.ready; + + animation.pause(); + await animation.ready; + + const currentTimeAfterPausing = animation.currentTime; + + scroller.scrollTop = 0.2 * maxScroll; + await waitForNextFrame(); + assert_percents_equal(animation.timeline.currentTime, 20, + 'Sanity check timeline time changed'); + + assert_percents_equal(animation.currentTime, currentTimeAfterPausing, + 'Animation.currentTime is unchanged after pausing'); +}, 'The animation\'s current time remains fixed after pausing'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + + const originalReadyPromise = animation.ready; + animation.cancel(); + assert_equals(animation.startTime, null); + assert_equals(animation.currentTime, null); + + const readyPromise = animation.ready; + assert_not_equals(originalReadyPromise, readyPromise, + 'Canceling an animation should create a new ready promise'); + + animation.pause(); + assert_equals(animation.playState, 'paused', + 'Pausing a canceled animation should update the play state'); + assert_true(animation.pending, 'animation should be pause-pending'); + await animation.ready; + assert_false(animation.pending, + 'animation should no longer be pause-pending'); + assert_equals(animation.startTime, null, 'start time should be unresolved'); + assert_percents_equal(animation.currentTime, 0, + 'current time should be set to zero'); +}, 'Pausing a canceled animation sets the current time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + scroller.scrollTop; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive.'); + // Pause the animation when the timeline is inactive. + animation.pause(); + assert_equals(animation.currentTime, null, + 'The current time is null when the timeline is inactive.'); + assert_equals(animation.startTime, null, + 'The start time is null in Pending state.'); + await waitForNextFrame(); + assert_true(animation.pending, + 'Animation has pause pending task while the timeline is inactive.'); + assert_equals(animation.playState, 'paused', + `State is 'paused' in Pending state.`); +}, `Pause pending task doesn't run when the timeline is inactive.`); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.2 * maxScroll; + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + scroller.scrollTop; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive.'); + // Play the animation when the timeline is inactive. + animation.pause(); + + // Make the scroll timeline active. + scroller.style.overflow = 'auto'; + await animation.ready; + // Ready promise is resolved as a result of the timeline becoming active. + assert_percents_equal(animation.currentTime, 20, + 'Animation current time is resolved when the animation is ready.'); + assert_equals(animation.startTime, null, + 'Animation start time is unresolved when the animation is ready.'); +}, 'Animation start and current times are correct if scroll timeline is ' + + 'activated after animation.pause call.'); + +</script> +</body> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/play-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/play-animation.html new file mode 100644 index 0000000000..7d95eaa257 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/play-animation.html @@ -0,0 +1,276 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Playing an animation</title> +<link rel="help" + href="https://drafts.csswg.org/web-animations/#playing-an-animation-section"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> +.scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; +} + +.contents { + height: 1000px; + width: 100%; +} +</style> +<body> +<script> +'use strict'; + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + assert_equals(animation.startTime, null); + await animation.ready; + assert_percents_equal(animation.startTime, 0); + + animation.cancel(); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = maxScroll / 2; + animation.play(); + await animation.ready; + assert_percents_equal(animation.currentTime, 50); + +}, 'Playing an animations aligns the start time with the start of the active ' + + 'range'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.playbackRate = -1; + animation.play(); + await animation.ready; + assert_percents_equal(animation.startTime, 100); +}, 'Playing an animations with a negative playback rate aligns the start ' + + 'time with the end of the active range'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.startTime = CSSNumericValue.parse("10%"); + await animation.ready; + assert_percents_equal(animation.startTime, 10); +}, 'Start time set while play pending is preserved.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.currentTime = CSSNumericValue.parse("10%"); + await animation.ready; + assert_percents_equal(animation.currentTime, 10); +}, 'Current time set while play pending is preserved.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + await animation.ready; + animation.currentTime = CSSNumericValue.parse("10%"); + assert_percents_equal(animation.currentTime, 10); + animation.play(); + await animation.ready; + assert_percents_equal(animation.currentTime, 0); +}, 'Playing a running animation resets a sticky start time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.finish(); + assert_percents_equal(animation.currentTime, 100); + animation.play(); + await animation.ready; + assert_percents_equal(animation.currentTime, 0); +}, 'Playing a finished animation restarts the animation aligned at the start'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.playbackRate = -1; + animation.currentTime = CSSNumericValue.parse("0%"); + assert_percents_equal(animation.currentTime, 0); + animation.play(); + await animation.ready; + + assert_percents_equal(animation.currentTime, 100); +}, 'Playing a finished and reversed animation restarts the animation aligned ' + + 'at the end'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.finish(); + await animation.finished; + + // Start time is now sticky since modified by an explicit API call. + // All current time calculations will be based on the new start time + // while running. + assert_percents_equal(animation.startTime, -100, + 'start time when finished'); + assert_percents_equal(animation.currentTime, 100, + 'current time when finished'); + + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = maxScroll / 2; + await waitForNextFrame(); + assert_percents_equal(animation.startTime, -100, + 'start time after scrolling a finished animation'); + // Clamped at effect end time. + assert_percents_equal(animation.currentTime, 100, + 'current time after scrolling a finished animation'); + + // Initiate a pause then abort it + animation.pause(); + animation.play(); + + // Wait to return to running state + await animation.ready; + + assert_percents_equal(animation.startTime, 0, + 'After aborting a pause when finished, the start time should no ' + + 'longer be sticky'); + assert_percents_equal(animation.currentTime, 50, + 'After aborting a pause when finished, the current time should realign ' + + 'with the scroll position'); +}, 'Playing a pause-pending but previously finished animation realigns' + + ' with the scroll position'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.finish(); + await animation.ready; + + animation.play(); + assert_equals(animation.startTime, null); + await animation.ready; + assert_percents_equal(animation.startTime, 0, 'start time is zero'); +}, 'Playing a finished animation clears the start time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.cancel(); + const promise = animation.ready; + animation.play(); + assert_not_equals(animation.ready, promise); +}, 'The ready promise should be replaced if the animation is not already' + + ' pending'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + const promise = animation.ready; + const promiseResult = await promise; + assert_equals(promiseResult, animation); + assert_equals(animation.ready, promise); +}, 'A pending ready promise should be resolved and not replaced when the' + + ' animation enters the running state'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.currentTime = CSSNumericValue.parse("50%"); + await animation.ready; + + assert_percents_equal(animation.currentTime, 50); + + animation.pause(); + await animation.ready; + + assert_percents_equal(animation.currentTime, 50); + + animation.play(); + await animation.ready; + + assert_percents_equal(animation.startTime, 0); +}, 'Resuming an animation from paused realigns with scroll position.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + await animation.ready; + + // Go to pause-pending state + animation.pause(); + assert_true(animation.pending, 'Animation is pending'); + const pauseReadyPromise = animation.ready; + + // Now play again immediately (abort the pause) + animation.play(); + assert_true(animation.pending, 'Animation is still pending'); + assert_equals(animation.ready, pauseReadyPromise, + 'The pause Promise is re-used when playing while waiting to pause'); + + // Sanity check: Animation proceeds to running state + await animation.ready; + assert_true(!animation.pending && animation.playState === 'running', + 'Animation is running after aborting a pause'); +}, 'If a pause operation is interrupted, the ready promise is reused'); + +promise_test(async t => { + // Seek animation beyond target end + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.currentTime = CSSNumericValue.parse("-100%"); + await animation.ready; + assert_percents_equal(animation.currentTime, -100); + + // Set pending playback rate to the opposite direction + animation.updatePlaybackRate(-1); + assert_true(animation.pending); + // Note: Updating the playback rate calls play without rewind. For a + // scroll-timeline, this will immediately apply the playback rate. + // TODO: Determine if we should defer applying the new playback rate. + assert_equals(animation.playbackRate, 1); + + // When we play, we should align to the target end, NOT to zero (which + // is where we would seek to if we used the playbackRate of 1. + animation.play(); + await animation.ready; + assert_percents_equal(animation.startTime, 100); + assert_percents_equal(animation.currentTime, 100); +}, 'A pending playback rate is used when determining timeline range alignment'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.cancel(); + assert_equals(animation.startTime, null, + 'Start time should be unresolved'); + + animation.play(); + assert_true(animation.pending, 'Animation should be play-pending'); + + await animation.ready; + + assert_false(animation.pending, 'animation should no longer be pending'); + assert_percents_equal(animation.startTime, 0, + 'The start time of the playing animation should be zero'); +}, 'Playing a canceled animation sets the start time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.playbackRate = -1; + animation.cancel(); + assert_equals(animation.startTime, null, 'Start time should be unresolved'); + + animation.play(); + assert_true(animation.pending, 'Animation should be play-pending'); + + await animation.ready; + + assert_false(animation.pending, 'Animation should no longer be pending'); + assert_percents_equal(animation.startTime, 100, + 'The start time of the playing animation should be set'); +}, 'Playing a canceled animation backwards sets the start time'); + +</script> +</body> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay-ref.html new file mode 100644 index 0000000000..59366a88dd --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay-ref.html @@ -0,0 +1,45 @@ +<!DOCTYPE html> +<title>Reference for Web Animation with scroll timeline and effect delay tests</title> +<style> + #box { + width: 100px; + height: 100px; + background-color: green; + transform: translate(0, 100px); + opacity: 0.5; + will-change: transform; /* force compositing */ + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + } + + #scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; /* force compositing */ + } + + #contents { + height: 1000px; + width: 100%; + } +</style> + +<div id="box"></div> +<div id="covered"></div> +<div id="scroller"> + <div id="contents"></div> +</div> + +<script> + window.addEventListener('load', function() { + // Move the scroller to halfway. + const scroller = document.getElementById("scroller"); + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.75 * maxScroll; + }); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay.tentative.html new file mode 100644 index 0000000000..525d8448ff --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay.tentative.html @@ -0,0 +1,69 @@ +<html class="reftest-wait"> +<title>Animation effect delays should be accounted for when using a progress based timeline</title> +<link rel="help" href="https://drafts.csswg.org/scroll-animations/"> +<meta name="assert" content="Effect delay should be accounted for by progress based animations"> +<link rel="match" href="progress-based-effect-delay-ref.html"> + +<script src="/web-animations/testcommon.js"></script> +<script src="/common/reftest-wait.js"></script> + +<style> + #box { + width: 100px; + height: 100px; + background-color: green; + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + } + + #scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; /* force compositing */ + } + + #contents { + height: 1000px; + width: 100%; + } +</style> + +<div id="box"></div> +<div id="covered"></div> +<div id="scroller"> + <div id="contents"><p>Scrolling Contents</p></div> +</div> +<script> + const box = document.getElementById('box'); + const effect = new KeyframeEffect(box, + [ + { transform: 'translateY(0)', opacity: 1}, + { transform: 'translateY(200px)', opacity: 0} + ], { + delay: 1000, + duration: 1000 + } + ); + + const scroller = document.getElementById('scroller'); + const timeline = new ScrollTimeline( + { source: scroller, orientation: 'block' }); + const animation = new Animation(effect, timeline); + + animation.play(); + + animation.ready.then(() => { + // Move the scroller to the halfway point. + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.75 * maxScroll; + + waitForAnimationFrames(2).then(_ => { + takeScreenshot(); + }); + }); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/reverse-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/reverse-animation.html new file mode 100644 index 0000000000..1054ed3983 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/reverse-animation.html @@ -0,0 +1,164 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Reversing an animation</title> +<link rel="help" + href="https://drafts.csswg.org/web-animations/#reversing-an-animation-section"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> +.scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; +} + +.contents { + height: 1000px; + width: 100%; +} +</style> +<body> +<script> +'use strict'; + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.reverse(); + animation.currentTime = CSSNumericValue.parse("40%"); + await animation.ready; + assert_percents_equal(animation.currentTime, 40); +}, 'Setting current time while reverse-pending preserves currentTime'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + await animation.ready; + + animation.currentTime = CSSNumericValue.parse("50%"); + const previousPlaybackRate = animation.playbackRate; + animation.reverse(); + assert_equals(animation.playbackRate, previousPlaybackRate, + 'Playback rate should not have changed'); + await animation.ready; + + assert_equals(animation.playbackRate, -previousPlaybackRate, + 'Playback rate should be inverted'); +}, 'Reversing an animation inverts the playback rate'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.currentTime = CSSNumericValue.parse("40%"); + await animation.ready; + assert_percents_equal(animation.startTime, -40); + assert_percents_equal(animation.currentTime, 40); + + animation.reverse(); + await animation.ready; + assert_percents_equal(animation.startTime, 100); + assert_percents_equal(animation.currentTime, 100); +}, 'Reversing an animation resets a sticky start time.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + assert_true(animation.pending, + 'The animation is pending before we call reverse'); + + animation.reverse(); + + assert_true(animation.pending, + 'The animation is still pending after calling reverse'); +}, 'Reversing an animation does not cause it to leave the pending state'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + let readyResolved = false; + animation.ready.then(() => { readyResolved = true; }); + + animation.reverse(); + + await Promise.resolve(); + assert_false(readyResolved, + 'ready promise should not have been resolved yet'); +}, 'Reversing an animation does not cause it to resolve the ready promise'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.playbackRate = -1; + animation.reverse(); + await animation.ready; + + assert_percents_equal(animation.currentTime, 0); +}, 'Reversing an animation with a negative playback rate should cause ' + + 'the animation to play in a forward direction'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.playbackRate = 0; + animation.currentTime = CSSNumericValue.parse("50%"); + animation.reverse(); + + await animation.ready; + assert_equals(animation.playbackRate, 0, + 'reverse() should preserve playbackRate if the playbackRate == 0'); + assert_percents_equal(animation.currentTime, 0, + 'Anchors to range start boundary when playback rate is zero'); +}, 'Reversing when when playbackRate == 0 should preserve the playback rate'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + assert_equals(animation.currentTime, null); + + animation.reverse(); + await animation.ready; + + assert_percents_equal(animation.startTime, 100, + 'animation.startTime should be at its effect end'); + assert_percents_equal(animation.currentTime, 100, + 'animation.currentTime should be at its effect end'); +}, 'Reversing an idle animation aligns startTime with the rangeEnd boundary'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + scroller.scrollTop; + await waitForNextFrame(); + + assert_throws_dom('InvalidStateError', () => { animation.reverse(); }); +}, 'Reversing an animation without an active timeline throws an ' + + 'InvalidStateError'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + animation.currentTime = CSSNumericValue.parse("50%"); + animation.pause(); + await animation.ready; + + animation.reverse(); + assert_equals(animation.playState, 'running', + 'Animation.playState should be "running" after reverse()'); +}, 'Reversing an animation plays a pausing animation'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + await animation.ready; + + animation.updatePlaybackRate(2); + animation.reverse(); + + await animation.ready; + assert_equals(animation.playbackRate, -2); +}, 'Reversing should use the negative pending playback rate'); +</script> +</body> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-fill-modes.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-fill-modes.tentative.html new file mode 100644 index 0000000000..b9cc154676 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-fill-modes.tentative.html @@ -0,0 +1,137 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Verify applied effect output for all fill modes in all timeline states: before start, at start, in range, at end, after end while using various effect delay values</title> +<meta name="timeout" content="long"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> + .scroller { + overflow: hidden; + height: 200px; + width: 200px; + } + .contents { + /* Make scroll range 1000 to simplify the math and avoid rounding errors */ + height: 1200px; + width: 100%; + } +</style> +<div id="log"></div> +<script> + 'use strict'; + + test(t => { + const effect = new KeyframeEffect(createDiv(t), { opacity: [0.3, 0.7] }); + const animation = new Animation(effect, createScrollTimeline(t)); + + assert_equals(effect.getTiming().fill, "auto"); + assert_equals(effect.getComputedTiming().fill, "none"); + }, "Scroll based animation effect fill mode should return 'auto' for" + + " getTiming() and should return 'none' for getComputedTiming().") + + /* All interesting transitions: + before start delay + at start delay + within active phase + at effect end + after effect end + + test_case data structure: + fill_mode: { + scroll_percentage: ["state description", expected applied effect value] + } + */ + const test_cases = { + "none": { + 0.10: ["before start delay", 1], + 0.25: ["at start delay", 0.3], + 0.50: ["at midpoint", 0.5], + 0.75: ["at effect end", 1], + 0.90: ["after effect end", 1] + }, + "backwards": { + 0.10: ["before start delay", 0.3], + 0.25: ["at start delay", 0.3], + 0.50: ["at midpoint", 0.5], + 0.75: ["at effect end", 1], + 0.90: ["after effect end", 1] + }, + "forwards": { + 0.10: ["before timeline start", 1], + 0.25: ["at timeline start", 0.3], + 0.50: ["in timeline range", 0.5], + 0.75: ["at timeline end", 0.7], + 0.90: ["after timeline end", 0.7] + }, + "both": { + 0.10: ["before timeline start", 0.3], + 0.25: ["at timeline start", 0.3], + 0.50: ["in timeline range", 0.5], + 0.75: ["at timeline end", 0.7], + 0.90: ["after timeline end", 0.7] + }, + "auto": { + 0.10: ["before timeline start", 1], + 0.25: ["at timeline start", 0.3], + 0.50: ["in timeline range", 0.5], + 0.75: ["at timeline end", 1], + 0.90: ["after timeline end", 1] + } + } + + for (const fill_mode in test_cases) { + const scroll_percents = test_cases[fill_mode] + + for (const scroll_percentage in scroll_percents) { + const expectation = scroll_percents[scroll_percentage]; + + const [test_name, expected_value] = expectation; + + const description = + `Applied effect value ${test_name} with fill: ${fill_mode}` + promise_test( + create_scroll_timeline_fill_test( + fill_mode, scroll_percentage, expected_value), + description); + } + } + + function create_scroll_timeline_fill_test( + fill_mode, scroll_percentage, expected){ + return async t => { + const target = createDiv(t); + + const timeline = createScrollTimeline(t); + const effect = + new KeyframeEffect(target, + { opacity: [0.3, 0.7] }, + { + fill: fill_mode, + /* Animation times normalized to fill scroll + range */ + duration: 2000, + delay: 1000, + endDelay: 1000 + }); + const animation = new Animation(effect, timeline); + const scroller = timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + animation.play(); + + await animation.ready; + + scroller.scrollTop = scroll_percentage * maxScroll; + + // Wait for new animation frame which allows the timeline to compute + // new current time. + await waitForNextFrame(); + + assert_equals(parseFloat(window.getComputedStyle(target).opacity), + expected, + "animation effect applied property value"); + } + } +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-phases.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-phases.tentative.html new file mode 100644 index 0000000000..41ae0e0612 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-phases.tentative.html @@ -0,0 +1,555 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Verify timeline time, animation time, effect time, and effect progress for all timeline states: before start, at start, in range, at end, after end while using various effect delay values</title> +<meta name="timeout" content="long"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> + .scroller { + overflow: hidden; + height: 200px; + width: 200px; + } + .contents { + /* Make scroll range 1000 to simplify the math and avoid rounding errors */ + height: 1200px; + width: 100%; + } +</style> +<div id="log"></div> +<script> + 'use strict'; + // Note: effects are scaled to fill the timeline. + + // Each entry is [[test input], [test expectations]] + // test input = ["description", delay, end_delay, scroll percent] + // test expectations = [timeline time, animation current time, + // effect local time, effect progress, effect phase, + // opacity] + + /* All interesting transitions: + at timeline start + before effect delay + at effect start + in active range + at effect end + after effect end + at timeline end + */ + const test_cases = [ + // Case 1: No delays. + // Boundary at end of active phase is inclusive. + [ + ["at start", 0, 0, 0], + [0, 0, 0, 0, "active", 0.3] + ], + [ + ["in active range", 0, 0, 0.50], + [50, 50, 50, 0.5, "active", 0.5] + ], + [ + ["at effect end time", 0, 0, 1.0], + [100, 100, 100, 1.0, "active", 0.7] + ], + + // Case 2: Positive start delay and no end delay. + // Boundary at end of active phase is inclusive. + [ + ["at timeline start", 500, 0, 0], + [0, 0, 0, null, "before", 1] + ], + [ + ["before start delay", 500, 0, 0.25], + [25, 25, 25, null, "before", 1] + ], + [ + ["at start delay", 500, 0, 0.5], + [50, 50, 50, 0, "active", 0.3] + ], + [ + ["in active range", 500, 0, 0.75], + [75, 75, 75, 0.5, "active", 0.5] + ], + [ + ["at effect end time", 500, 0, 1.0], + [100, 100, 100, 1.0, "active", 0.7] + ], + + // case 3: No start delay, Positive end delay. + // Boundary at end of active phase is exclusive. + [ + ["at timeline start", 0, 500, 0], + [0, 0, 0, 0, "active", 0.3] + ], + [ + ["in active range", 0, 500, 0.25], + [25, 25, 25, 0.5, "active", 0.5] + ], + [ + ["at effect end time", 0, 500, 0.5], + [50, 50, 50, null, "after", 1.0] + ], + [ + ["after effect end time", 0, 500, 0.75], + [75, 75, 75, null, "after", 1.0] + ], + [ + ["at timeline boundary", 0, 500, 1.0], + [100, 100, 100, null, "after", 1.0] + ], + + // case 4: Positive start and end delays. + // Boundary at end of active phase is exclusive. + [ + ["at timeline start", 250, 250, 0], + [0, 0, 0, null, "before", 1] + ], + [ + ["before start delay", 250, 250, 0.1], + [10, 10, 10, null, "before", 1] + ], + [ + ["at start delay", 250, 250, 0.25], + [25, 25, 25, 0, "active", 0.3] + ], + [ + ["in active range", 250, 250, 0.5], + [50, 50, 50, 0.5, "active", 0.5] + ], + [ + ["at effect end time", 250, 250, 0.75], + [75, 75, 75, null, "after", 1.0] + ], + [ + ["after effect end time", 250, 250, 0.9], + [90, 90, 90, null, "after", 1.0] + ], + [ + ["at timeline boundary", 250, 250, 1.0], + [100, 100, 100, null, "after", 1.0] + ], + + // Case 5: Negative start and end delays. + // Effect boundaries are not reachable. + [ + ["at timeline start", -125, -125, 0], + [0, 0, 0, 0.25, "active", 0.4] + ], + [ + ["in active range", -125, -125, 0.5], + [50, 50, 50, 0.5, "active", 0.5] + ], + [ + ["at timeline end", -125, -125, 1.0], + [100, 100, 100, 0.75, "active", 0.6] + ] + ]; + + for (const test_case of test_cases) { + const [inputs, expected] = test_case; + const [test_name, delay, end_delay, scroll_percentage] = inputs; + + const description = `Current times and effect phase ${test_name} when` + + ` delay = ${delay} and endDelay = ${end_delay} |`; + + promise_test( + create_scroll_timeline_delay_test( + delay, end_delay, scroll_percentage, expected), + description); + } + + function create_scroll_timeline_delay_test( + delay, end_delay, scroll_percentage, expected){ + return async t => { + const target = createDiv(t); + const timeline = createScrollTimeline(t); + const effect = new KeyframeEffect( + target, + { + opacity: [0.3, 0.7] + }, + { + duration: 500, + delay: delay, + endDelay: end_delay + } + ); + const animation = new Animation(effect, timeline); + t.add_cleanup(() => { + animation.cancel(); + }); + const scroller = timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + animation.play(); + + await animation.ready; + + scroller.scrollTop = scroll_percentage * maxScroll; + + // Wait for new animation frame which allows the timeline to compute + // new current time. + await waitForNextFrame(); + + const [expected_timeline_current_time, + expected_animation_current_time, + expected_effect_local_time, + expected_effect_progress, + expected_effect_phase, + expected_opacity] = expected; + + assert_percents_equal( + animation.timeline.currentTime, + expected_timeline_current_time, + "timeline current time"); + assert_percents_equal( + animation.currentTime, + expected_animation_current_time, + "animation current time"); + assert_percents_equal( + animation.effect.getComputedTiming().localTime, + expected_effect_local_time, + "animation effect local time"); + assert_approx_equals_or_null( + animation.effect.getComputedTiming().progress, + expected_effect_progress, + 0.001, + "animation effect progress"); + assert_phase( + animation, expected_effect_phase); + assert_approx_equals( + parseFloat(getComputedStyle(target).opacity), expected_opacity, + 0.001, + 'target opacity'); + } + } + + function createKeyframeEffectOpacity(test){ + return new KeyframeEffect( + createDiv(test), + { + opacity: [0.3, 0.7] + }, + { + duration: 1000 + } + ); + } + + function verifyEffectBeforePhase(animation) { + // If currentTime is null, we are either idle, or running with an + // inactive timeline. Either way, the animation is not in effect and cannot + // be in the before phase. + assert_true(animation.currentTime != null, + 'Animation is not in effect'); + + const fillMode = animation.effect.getTiming().fill; + animation.effect.updateTiming({ fill: 'none' }); + + // progress == null AND opacity == 1 implies we are in the effect before + // or after phase. + assert_equals(animation.effect.getComputedTiming().progress, null); + assert_equals( + window.getComputedStyle(animation.effect.target) + .getPropertyValue("opacity"), + "1"); + + // If the progress is no longer null after adding fill: backwards, then we + // are in the before phase. + animation.effect.updateTiming({ fill: 'backwards' }); + assert_true(animation.effect.getComputedTiming().progress != null); + assert_equals( + window.getComputedStyle(animation.effect.target) + .getPropertyValue("opacity"), + "0.3"); + + // Reset fill mode to avoid side-effects. + animation.effect.updateTiming({ fill: fillMode }); + } + + function createScrollLinkedOpacityAnimationWithDelays(t) { + const animation = new Animation( + createKeyframeEffectOpacity(t), + createScrollTimeline(t) + ); + t.add_cleanup(() => { + animation.cancel(); + }); + animation.effect.updateTiming({ + duration: 1000, + delay: 500, + endDelay: 500 + }); + return animation; + } + + + promise_test(async t => { + const animation = createScrollLinkedOpacityAnimationWithDelays(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // scroll pos + // current time + // start time + // | + // |---- 25% before ----|---- 50% active ----|---- 25% after ----| + animation.play(); + await animation.ready; + assert_percents_equal(animation.startTime, 0); + assert_phase(animation, 'before'); + + // start time scroll pos + // | current time + // | | + // |---- 25% before ----|---- 50% active ----|---- 25% after ----| + scroller.scrollTop = 0.5 * maxScroll; + await waitForNextFrame(); + assert_phase(animation, 'active'); + + // start time scroll pos current time + // | | | + // |---- 25% before ----|---- 50% active ----|---- 25% after ----| + animation.playbackRate = 2; + assert_phase(animation, 'after'); + + // start time scroll pos current time + // | | | + // |---- 33.3% before ----|---- 66.7% active ---------------------| + animation.effect.updateTiming({ endDelay: 0 }); + assert_phase(animation, 'active'); + + // scroll pos start time + // current time | + // | | + // |---- 33.3% before ----|---- 66.7% active ----------------------| + animation.playbackRate = -1; + assert_percents_equal(animation.startTime, 100); + assert_phase(animation, 'active'); + + // start time + // scroll pos current time + // | | | + // |---- 33.3% before ----|---- 66.7% active -----------------------| + animation.playbackRate = -2; + assert_phase(animation, 'active'); + + // current time start time + // | scroll pos + // | | + // |---- 33.3% before ----|---- 66.7% active -----------------------| + scroller.scrollTop = maxScroll; + await waitForNextFrame(); + assert_phase(animation, 'before'); + + // current time start time + // | scroll pos + // | | + // |--------------------- 100% active -------------------------------| + animation.effect.updateTiming({ delay: 0 }); + assert_phase(animation, 'active'); + + // Finally, switch to a document timeline. The before-active boundary + // becomes exclusive. + animation.timeline = document.timeline; + animation.currentTime = 0; + await waitForNextFrame(); + assert_phase(animation, 'before'); + + }, 'Playback rate affects whether active phase boundary is inclusive.'); + + promise_test(async t => { + const animation = createScrollLinkedOpacityAnimationWithDelays(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + animation.play(); + await animation.ready; + verifyEffectBeforePhase(animation); + + animation.pause(); + await waitForNextFrame(); + verifyEffectBeforePhase(animation); + + animation.play(); + await waitForNextFrame(); + + verifyEffectBeforePhase(animation); + }, 'Verify that (play -> pause -> play) doesn\'t change phase/progress.'); + + promise_test(async t => { + const animation = createScrollLinkedOpacityAnimationWithDelays(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + animation.play(); + await animation.ready; + verifyEffectBeforePhase(animation); + + animation.pause(); + await animation.ready; + verifyEffectBeforePhase(animation); + + // Scrolling should not cause the animation effect to change. + scroller.scrollTop = 0.5 * maxScroll; + await waitForNextFrame(); + + // Check timeline phase + assert_percents_equal(animation.timeline.currentTime, 50); + assert_percents_equal(animation.currentTime, 0); + assert_percents_equal(animation.effect.getComputedTiming().localTime, 0, + "effect local time"); + + // Make sure the effect is still in the before phase even though the + // timeline is not. + verifyEffectBeforePhase(animation); + }, 'Pause in before phase, scroll timeline into active phase, animation ' + + 'should remain in the before phase'); + + promise_test(async t => { + const animation = createScrollLinkedOpacityAnimationWithDelays(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + animation.play(); + await animation.ready; + verifyEffectBeforePhase(animation); + + animation.pause(); + await waitForNextFrame(); + verifyEffectBeforePhase(animation); + + // Setting the current time should force the animation into effect. + const expected_time = 50; + animation.currentTime = CSS.percent(expected_time); + await waitForNextFrame(); + assert_percents_equal(animation.timeline.currentTime, 0); + assert_percents_equal(animation.currentTime, expected_time, + 'Current time matches set value'); + assert_percents_equal( + animation.effect.getComputedTiming().localTime, + expected_time, "Effect local time after setting animation.currentTime"); + assert_equals(animation.effect.getComputedTiming().progress, 0.5, + "Progress after setting animation.currentTime"); + assert_equals( + window.getComputedStyle(animation.effect.target) + .getPropertyValue("opacity"), + "0.5", "Opacity after setting animation.currentTime"); + + // Scrolling should not cause the animation effect to change since + // paused. + scroller.scrollTop = 0.75 * maxScroll; // scroll so that timeline is 75% + await waitForNextFrame(); + assert_percents_equal(animation.timeline.currentTime, 75); + + // animation and effect timings are unchanged. + assert_percents_equal(animation.currentTime, expected_time, + "Current time after scrolling while paused"); + assert_percents_equal( + animation.effect.getComputedTiming().localTime, + expected_time, + "Effect local time after scrolling while paused"); + assert_equals(animation.effect.getComputedTiming().progress, 0.5, + "Progress after scrolling while paused"); + assert_equals( + window.getComputedStyle(animation.effect.target) + .getPropertyValue("opacity"), + "0.5", "Opacity after scrolling while paused"); + }, 'Pause in before phase, set animation current time to be in active ' + + 'range, animation should become active. Scrolling should have no effect.'); + + promise_test(async t => { + const animation = createScrollLinkedOpacityAnimationWithDelays(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + animation.play(); + await animation.ready; + + // Causes the timeline to be inactive + scroller.style.overflow = "visible"; + await waitForNextFrame(); + await waitForNextFrame(); + + // Verify that he timeline is inactive + assert_equals(animation.timeline.currentTime, null, + "Timeline is inactive"); + assert_equals( + animation.currentTime, null, + "Current time for running animation with an inactive timeline"); + assert_equals(animation.effect.getComputedTiming().localTime, null, + "effect local time with inactive timeline"); + + // Setting the current time while timeline is inactive should pause the + // animation at the specified time. + animation.currentTime = CSS.percent(50); + await waitForNextFrame(); + await waitForNextFrame(); + + // Verify that animation currentTime is properly set despite the inactive + // timeline. + assert_equals(animation.timeline.currentTime, null); + assert_percents_equal(animation.currentTime, 50); + assert_percents_equal(animation.effect.getComputedTiming().localTime, 50, + "effect local time after setting animation current time"); + + // Check effect phase + // progress == 0.5 AND opacity == 0.5 shows we are in the effect active + // phase. + assert_equals(animation.effect.getComputedTiming().progress, 0.5, + "effect progress"); + assert_equals( + window.getComputedStyle(animation.effect.target) + .getPropertyValue("opacity"), + "0.5", + "effect opacity after setting animation current time"); + }, 'Make scroller inactive, then set current time to an in range time'); + + promise_test(async t => { + const animation = createScrollLinkedOpacityAnimationWithDelays(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.5 * maxScroll; + // Update timeline.currentTime. + await waitForNextFrame(); + + animation.pause(); + await animation.ready; + // verify effect is applied. + const expected_progress = 0.5; + assert_equals( + animation.effect.getComputedTiming().progress, + expected_progress, + "Verify effect progress after pausing."); + + // cause the timeline to become inactive + scroller.style.overflow = 'visible'; + await waitForAnimationFrames(2); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive.'); + assert_equals( + animation.effect.getComputedTiming().progress, + expected_progress, + "Verify effect progress after the timeline goes inactive."); + }, 'Animation effect is still applied after pausing and making timeline ' + + 'inactive.'); + + promise_test(async t => { + const animation = createScrollLinkedOpacityAnimationWithDelays(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + animation.play(); + await animation.ready; + + // cause the timeline to become inactive + scroller.style.overflow = 'visible'; + + scroller.scrollTop; + + animation.pause(); + }, 'Make timeline inactive, force style update then pause the animation. ' + + 'No crashing indicates test success.'); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-inactive-timeline.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-inactive-timeline.html new file mode 100644 index 0000000000..02220cee14 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-inactive-timeline.html @@ -0,0 +1,170 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Test basic functionality of scroll linked animation.</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> + .scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; + } + + .contents { + height: 1000px; + width: 100%; + } +</style> +<div id="log"></div> +<script> +'use strict'; + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + + // Ensure we have a valid animation frame time before continuing the test. + // This is so that we can properly determine frame advancement after the + // style change. + await waitForNextFrame(); + + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive.'); + // Play the animation when the timeline is inactive. + animation.play(); + assert_equals(animation.currentTime, null, + 'The current time is null when the timeline is inactive.'); + assert_equals(animation.startTime, null, + 'The start time is unresolved while play-pending.'); + await waitForNextFrame(); + assert_true(animation.pending, + 'Animation has play pending task while timeline is inactive.'); + assert_equals(animation.playState, 'running', + `State is 'running' in Pending state.`); +}, `Play pending task doesn't run when the timeline is inactive.`); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + + await waitForNextFrame(); + + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive.'); + // Play the animation when the timeline is inactive. + animation.play(); + + // Make the scroll timeline active. + scroller.style.overflow = 'auto'; + await animation.ready; + // Ready promise is resolved as a result of the timeline becoming active. + assert_percents_equal(animation.currentTime, 0, + 'Animation current time is resolved when the animation is ready.'); + assert_percents_equal(animation.startTime, 0, + 'Animation start time is resolved when the animation is ready.'); +}, 'Animation start and current times are correct if scroll timeline is ' + + 'activated after animation.play call.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const target = animation.effect.target; + + await waitForNextFrame(); + + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + scroller.scrollTop; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive.'); + // Set start time when the timeline is inactive. + animation.startTime = CSSNumericValue.parse("0%"); + assert_equals(animation.currentTime, null, + 'Sanity check current time is unresolved when the timeline ' + + 'is inactive.'); + + // Make the scroll timeline active. + scroller.style.overflow = 'auto'; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + assert_percents_equal(animation.currentTime, 0, + 'Animation current time is resolved when the timeline is active.'); + assert_percents_equal(animation.startTime, 0, + 'Animation start time is resolved.'); + assert_percents_equal(animation.effect.getComputedTiming().localTime, 0, + 'Effect local time is resolved when the timeline is active.'); + assert_equals(Number(getComputedStyle(target).opacity), 0, + 'Animation has an effect when the timeline is active.'); +}, 'Animation start and current times are correct if scroll timeline is ' + + 'activated after setting start time.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + const target = animation.effect.target; + + await waitForNextFrame(); + + // Advance the scroller. + scroller.scrollTop = 0.2 * maxScroll; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + // Play the animation when the timeline is active. + animation.play(); + await animation.ready; + + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + scroller.scrollTop; + await waitForNextFrame(); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive.'); + assert_equals(animation.playState, 'running', + `State is 'running' when the timeline is inactive.`); + assert_equals(animation.currentTime, null, + 'Current time is unresolved when the timeline is inactive.'); + assert_percents_equal(animation.startTime, 0, + 'Start time is zero when the timeline is inactive.'); + assert_equals(animation.effect.getComputedTiming().localTime, null, + 'Effect local time is null when the timeline is inactive.'); + assert_equals(Number(getComputedStyle(target).opacity), 1, + 'Animation does not have an effect when the timeline is inactive.'); + + // Make the scroll timeline active. + scroller.style.overflow = 'auto'; + await waitForNextFrame(); + + assert_equals(animation.playState, 'running', + `State is 'running' when the timeline is active.`); + assert_percents_equal(animation.currentTime, 20, + 'Current time is resolved when the timeline is active.'); + assert_percents_equal(animation.startTime, 0, + 'Start time is zero when the timeline is active.'); + assert_percents_equal(animation.effect.getComputedTiming().localTime, 20, + 'Effect local time is resolved when the timeline is active.'); + assert_equals(Number(getComputedStyle(target).opacity), 0.2, + 'Animation has an effect when the timeline is active.'); +}, 'Animation current time is correct when the timeline becomes newly ' + + 'inactive and then active again.'); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation.html new file mode 100644 index 0000000000..e3544762f6 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation.html @@ -0,0 +1,160 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Test basic functionality of scroll linked animation.</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> + .scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; + } + .contents { + height: 1000px; + width: 100%; + } +</style> +<div id="log"></div> +<script> + 'use strict'; + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Verify initial start and current times in Idle state. + assert_equals(animation.currentTime, null, + "The current time is null in Idle state."); + assert_equals(animation.startTime, null, + "The start time is null in Idle state."); + animation.play(); + assert_true(animation.pending, "Animation is in the pending state."); + // Verify initial start and current times in the pending state. + assert_equals(animation.currentTime, null, + "The current time remains null while in the pending state."); + assert_equals(animation.startTime, null, + "The start time remains null while in the pending state."); + + await animation.ready; + // Verify initial start and current times once ready. + assert_percents_equal(animation.currentTime, 0, + "The current time is resolved when ready."); + assert_percents_equal(animation.startTime, 0, + "The start time is resolved when ready."); + + // Now do some scrolling and make sure that the Animation current time is + // correct. + scroller.scrollTop = 0.4 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + assert_percents_equal(animation.currentTime, animation.timeline.currentTime, + "The current time corresponds to the scroll position of the scroller."); + assert_times_equal( + animation.effect.getComputedTiming().progress, + animation.timeline.currentTime.value / 100, + 'Effect progress corresponds to the scroll position of the scroller.'); +}, 'Animation start and current times are correct for each animation state.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Advance the scroller. + scroller.scrollTop = 0.2 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + // Verify initial start and current times in Idle state. + assert_equals(animation.currentTime, null, + "The current time is null in Idle state."); + assert_equals(animation.startTime, null, + "The start time is null in Idle state."); + animation.play(); + // Verify initial start and current times in Pending state. + assert_equals(animation.currentTime, null, + "The current time remains unresolved while play-pending."); + assert_equals(animation.startTime, null, + "The start time remains unresolved while play-pending."); + + await animation.ready; + // Verify initial start and current times in Playing state. + assert_percents_equal(animation.currentTime, animation.timeline.currentTime, + "The current corresponds to the scroll position of the scroller."); + assert_percents_equal(animation.startTime, 0, + "The start time is zero in Playing state."); +}, 'Animation start and current times are correct for each animation state' + + ' when the animation starts playing with advanced scroller.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + animation.play(); + await animation.ready; + // Advance the scroller to max position. + scroller.scrollTop = maxScroll; + + await animation.finished; + + assert_equals(animation.playState, 'finished', + 'Animation state is in finished state.'); + assert_percents_equal(animation.currentTime, 100, + 'Animation current time is at 100% on reverse scrolling.'); + + // Scroll back. + scroller.scrollTop = 0.2 * maxScroll; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + // Verify animation state and current time on reverse scrolling. + assert_equals(animation.playState, 'running', + 'Animation state is playing on reverse scrolling.'); + assert_percents_equal(animation.currentTime, 20, + 'Animation current time is updated on reverse scrolling.'); +}, 'Finished animation plays on reverse scrolling.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + animation.play(); + await animation.ready; + + // Advance the scroller to max position. + scroller.scrollTop = maxScroll; + await animation.finished; + + var sent_finish_event = false; + animation.onfinish = function() { + sent_finish_event = true; + }; + + // Scroll back. + scroller.scrollTop = 0.2 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + assert_false(sent_finish_event, + "No animation finished event is sent on reverse scroll."); + + scroller.scrollTop = maxScroll; + await animation.finished; + + // Wait for next frame to allow the animation to send finish events. The + // finished promise fires before events are sent. + await waitForNextFrame(); + + assert_true(sent_finish_event, + "Animation finished event is sent on reaching max scroll."); +}, 'Sending animation finished events by finished animation on reverse ' + + 'scrolling.'); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-in-removed-iframe-crash.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-in-removed-iframe-crash.html new file mode 100644 index 0000000000..07edbd83f1 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-in-removed-iframe-crash.html @@ -0,0 +1,20 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Starting an animation with a scroll timeline in a removed iframe + should not crash</title> +</head> +<body> + <div id="target"></div> + <iframe id="frame"></iframe> +</body> +<script type="text/javascript"> + const target = document.getElementById('target'); + const frame = document.getElementById('frame'); + const timeline = new frame.contentWindow.ScrollTimeline(); + frame.remove(); + target.animate(null, {timeline: timeline}); +</script> +</html> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-invalidation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-invalidation.html new file mode 100644 index 0000000000..a26500989e --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-invalidation.html @@ -0,0 +1,133 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>ScrollTimeline invalidation</title> +<link rel="help" href="https://wicg.github.io/scroll-animations/#current-time-algorithm"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> + .scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; + } + .contents { + height: 1000px; + width: 100%; + } +</style> +<div id="log"></div> + +<script> +'use strict'; + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const effect_duration = 350; + animation.effect.updateTiming({ duration: effect_duration }); + const scroller = animation.timeline.source; + let maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.2 * maxScroll; + const initial_progress = (scroller.scrollTop / maxScroll) * 100; + + animation.play(); + await animation.ready; + + // Animation current time is at 20% because scroller was scrolled to 20% + assert_percents_equal(animation.currentTime, 20); + assert_equals(scroller.scrollTop, 180); + assert_equals(maxScroll, 900); + + // Shrink scroller content size (from 1000 to 500). + // scroller.scrollTop maintains the same offset, which means shrinking the + // content has the effect of skipping the animation forward. + scroller.firstChild.style.height = "500px"; + maxScroll = scroller.scrollHeight - scroller.clientHeight; + + assert_equals(scroller.scrollTop, 180); + assert_equals(maxScroll, 400); + await waitForNextFrame(); + + const expected_progress = (scroller.scrollTop / maxScroll) * 100; + assert_true(expected_progress > initial_progress) + // @ 45% + assert_percents_equal(animation.currentTime, expected_progress); + assert_percents_equal(animation.timeline.currentTime, expected_progress); + assert_percents_equal(animation.effect.getComputedTiming().localTime, expected_progress); +}, 'Animation current time and effect local time are updated after scroller ' + + 'content size changes.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.effect.updateTiming({ duration: 350 }); + const scroller = animation.timeline.source; + let maxScroll = scroller.scrollHeight - scroller.clientHeight; + const scrollOffset = 0.2 * maxScroll + scroller.scrollTop = scrollOffset; + const initial_progress = (scroller.scrollTop / maxScroll) * 100; + + animation.play(); + await animation.ready; + + // Animation current time is at 20% because scroller was scrolled to 20% + // assert_equals(animation.currentTime.value, 20); + assert_percents_equal(animation.currentTime, 20); + assert_equals(scroller.scrollTop, scrollOffset); + assert_equals(maxScroll, 900); + + // Change scroller size. + scroller.style.height = "500px"; + maxScroll = scroller.scrollHeight - scroller.clientHeight; + + assert_equals(scroller.scrollTop, scrollOffset); + assert_equals(maxScroll, 500); + await waitForNextFrame(); + + const expected_progress = (scroller.scrollTop / maxScroll) * 100; + assert_true(expected_progress > initial_progress); + // @ 45% + assert_percents_equal(animation.currentTime, expected_progress); + assert_percents_equal(animation.timeline.currentTime, expected_progress); + assert_percents_equal(animation.effect.getComputedTiming().localTime, + expected_progress); +}, 'Animation current time and effect local time are updated after scroller ' + + 'size changes.'); + +promise_test(async t => { + await waitForNextFrame(); + + const timeline = createScrollTimeline(t); + const scroller = timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + // Instantiate scroll animation that resizes its scroll timeline scroller. + const animation = new Animation( + new KeyframeEffect( + timeline.source.firstChild, + [{ height: '1000px', easing: 'steps(2, jump-none)'}, + { height: '2000px' }], + ), timeline); + + animation.play(); + await animation.ready; + + assert_percents_equal(timeline.currentTime, 0); + assert_equals(scroller.scrollHeight, 1000); + + await runAndWaitForFrameUpdate(() => { + scroller.scrollTop = 0.6 * maxScroll; + }); + + // Applying the animation effect alters the height of the scroll content and + // makes the scroll timeline stale. + // https://github.com/w3c/csswg-drafts/issues/8694 + + // With a single layout, timeline current time would be at 60%, but the + // timeline would be stale. + const expected_progress = 60 * maxScroll / (maxScroll + 1000); + assert_approx_equals(timeline.currentTime.value, expected_progress, 0.1); + +}, 'If scroll animation resizes its scroll timeline scroller, ' + + 'layout reruns once per frame.'); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-range.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-range.html new file mode 100644 index 0000000000..cc844cb748 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-range.html @@ -0,0 +1,185 @@ +<!DOCTYPE html> +<title>Scroll timelines and animation attachment ranges</title> +<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range"> +<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#animation-range"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="support/testcommon.js"></script> +<style> + #scroller { + overflow-y: scroll; + width: 200px; + height: 200px; + scroll-timeline: --t1; + } + .spacer { + height: 1100px; + } + #target { + height: 100px; + width: 0; + font-size: 10px; + background-color: green; + } + @keyframes grow { + to { width: 200px } + } + .anim-1 { + animation: auto grow linear; + animation-timeline: --t1; + animation-range-start: 25%; + animation-range-end: 75%; + } + .anim-2 { + animation: auto grow linear; + animation-timeline: --t1; + animation-range-start: 40px; + animation-range-end: 700px; + } + .anim-3 { + animation: auto grow linear; + animation-timeline: --t1; + animation-range-start: calc(30% + 40px); + animation-range-end: calc(70% - 20px); + } + .anim-4 { + animation: auto grow linear; + animation-timeline: --t1; + animation-range-start: 5em; + animation-range-end: calc(100% - 5em); + } + +</style> +<div id=scroller> + <div id=target></div> + <div class=spacer></div> +</div> +<script> + // Test via web-animation API. + + async function test_range_waapi(t, options) { + const timeline = new ScrollTimeline({source: scroller, axis: 'block'}); + const anim = + target.animate([{ width: "200px" }], + { + timeline: timeline, + rangeStart: options.rangeStart, + rangeEnd: options.rangeEnd + }); + t.add_cleanup(() => { + anim.cancel(); + }); + await anim.ready; + scroller.scrollTop = 600; + await waitForNextFrame(); + + const expectedProgress = + (600 - options.startOffset) / (options.endOffset - options.startOffset); + assert_approx_equals(anim.effect.getComputedTiming().progress, + expectedProgress, 0.001); + } + + promise_test(async t => { + await test_range_waapi(t, { + rangeStart: "25%", + rangeEnd: "75%", + startOffset: 250, + endOffset: 750 + }); + }, 'Scroll timeline with percentage range [JavaScript API]'); + + promise_test(async t => { + await test_range_waapi(t, { + rangeStart: "40px", + rangeEnd: "700px", + startOffset: 40, + endOffset: 700 + }); + }, 'Scroll timeline with px range [JavaScript API]'); + + promise_test(async t => { + await test_range_waapi(t, { + rangeStart: "calc(30% + 40px)", + rangeEnd: "calc(70% - 20px)", + startOffset: 340, + endOffset: 680 + }); + }, 'Scroll timeline with calculated range [JavaScript API]'); + +promise_test(async t => { + t.add_cleanup(() => { + target.style.fontSize = ''; + }); + await test_range_waapi(t, { + rangeStart: "5em", + rangeEnd: "calc(100% - 5em)", + startOffset: 50, + endOffset: 950 + }); + target.style.fontSize = '20px'; + await waitForNextFrame(); + const anim = target.getAnimations()[0]; + const expectedProgress = (600 - 100) / (900 - 100); + assert_approx_equals(anim.effect.getComputedTiming().progress, + expectedProgress, 0.001); + }, 'Scroll timeline with EM range [JavaScript API]'); + + // Test via CSS. + async function test_range_css(t, options) { + t.add_cleanup(() => { + target.classList.remove(options.animation); + }); + target.classList.add(options.animation); + const anim = target.getAnimations()[0]; + await anim.ready; + scroller.scrollTop = 600; + await waitForNextFrame(); + + const expectedProgress = + (600 - options.startOffset) / (options.endOffset - options.startOffset); + assert_approx_equals(anim.effect.getComputedTiming().progress, + expectedProgress, 0.001); + } + + promise_test(async t => { + await test_range_css(t, { + animation: "anim-1", + startOffset: 250, + endOffset: 750 + }); + }, 'Scroll timeline with percentage range [CSS]'); + + promise_test(async t => { + await test_range_css(t, { + animation: "anim-2", + startOffset: 40, + endOffset: 700 + }); + }, 'Scroll timeline with px range [CSS]'); + + promise_test(async t => { + await test_range_css(t, { + animation: "anim-3", + startOffset: 340, + endOffset: 680 + }); + }, 'Scroll timeline with calculated range [CSS]'); + +promise_test(async t => { + t.add_cleanup(() => { + target.style.fontSize = ''; + }); + await test_range_css(t, { + animation: "anim-4", + startOffset: 50, + endOffset: 950 + }); + target.style.fontSize = '20px'; + await waitForNextFrame(); + const anim = target.getAnimations()[0]; + const expectedProgress = (600 - 100) / (900 - 100); + assert_approx_equals(anim.effect.getComputedTiming().progress, + expectedProgress, 0.001); + }, 'Scroll timeline with EM range [CSS]'); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-snapshotting.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-snapshotting.html new file mode 100644 index 0000000000..1e43699d5b --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-snapshotting.html @@ -0,0 +1,44 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>ScrollTimeline snapshotting</title> +<link rel="help" href="https://wicg.github.io/scroll-animations/#avoiding-cycles"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script src="/resources/testdriver-actions.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="./testcommon.js"></script> + +<style> + body { + height: 800px; + width: 800px; + } +</style> +<div id="log"></div> + +<script> +'use strict'; + +promise_test(async t => { + const scroller = document.scrollingElement; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + const timeline = new ScrollTimeline({source: scroller}); + scroller.scrollTo(0, 0); + assert_equals(scroller.scrollTop, 0, "verify test pre-condition"); + + scroller.scrollBy({top: 100, behavior: 'smooth'}); + // Wait for the scroll to change. + const startScroll = scroller.scrollTop; + do { + await waitForNextFrame(); + } while(scroller.scrollTop == startScroll); + assert_percents_equal( + timeline.currentTime, + (scroller.scrollTop / maxScroll) * 100, + 'Scroll timeline current time corresponds to the scroll position.'); +}, 'ScrollTimeline current time is updated after programmatic animated ' + + 'scroll.'); + +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/set-current-time-before-play.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/set-current-time-before-play.html new file mode 100644 index 0000000000..280346e755 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/set-current-time-before-play.html @@ -0,0 +1,75 @@ +<html class="reftest-wait"> +<title>Setting current time before play should not timeout</title> +<link rel="help" href="https://drafts.csswg.org/scroll-animations/"> +<meta name="assert" content="Regression test to make sure the ready promise is correctly resolved"> +<link rel="match" href="animation-ref.html"> + +<script src="/web-animations/testcommon.js"></script> +<script src="/common/reftest-wait.js"></script> + +<style> + #box { + width: 100px; + height: 100px; + background-color: green; + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + } + + #scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; /* force compositing */ + } + + #contents { + height: 1000px; + width: 100%; + } +</style> + +<div id="box"></div> +<div id="covered"></div> +<div id="scroller"> + <div id="contents"></div> +</div> + +<script> + async function runTest() { + await waitForCompositorReady(); + + const box = document.getElementById('box'); + const effect = new KeyframeEffect(box, + [ + { transform: 'translateY(0)', opacity: 1}, + { transform: 'translateY(200px)', opacity: 0} + ], { + duration: 1000, + } + ); + + const scroller = document.getElementById('scroller'); + const timeline = new ScrollTimeline( + { source: scroller, orientation: 'block' }); + const animation = new Animation(effect, timeline); + animation.currentTime = CSS.percent(0); + animation.play(); + + animation.ready.then(() => { + // Move the scroller to the halfway point. + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.5 * maxScroll; + + waitForAnimationFrames(2).then(_ => { + takeScreenshot(); + }); + }); + } + + window.onload = runTest; +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-current-time.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-current-time.html new file mode 100644 index 0000000000..f6c826db69 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-current-time.html @@ -0,0 +1,286 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Setting the current time of an animation</title> +<link rel="help" href="https://drafts.csswg.org/web-animations-1/#setting-the-current-time-of-an-animation"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> +.scroller { + overflow: auto; + height: 200px; + width: 100px; + will-change: transform; +} +.contents { + height: 1000px; + width: 100%; +} +</style> +<body> +<div id="log"></div> +<script> +'use strict'; + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.25 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + + await animation.ready; + + assert_throws_js(TypeError, () => { + animation.currentTime = null; + }); +}, 'Setting animation current time to null throws TypeError.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + assert_throws_dom('NotSupportedError', () => { + animation.currentTime = CSSNumericValue.parse("300"); + }); + assert_throws_dom('NotSupportedError', () => { + animation.currentTime = CSSNumericValue.parse("300ms"); + }); + assert_throws_dom('NotSupportedError', () => { + animation.currentTime = CSSNumericValue.parse("0.3s"); + }); +}, 'Setting the current time to an absolute time value throws exception'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.25 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + animation.currentTime = CSSNumericValue.parse("33.3%"); + + assert_percents_equal(animation.currentTime, 33.3, + "Animation current time should be equal to the set value." + ); +}, 'Set animation current time to a valid value without playing.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.25 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + + await animation.ready; + animation.currentTime = CSSNumericValue.parse("33.3%"); + + assert_percents_equal(animation.currentTime, 33.3, + "Animation current time should be equal to the set value." + ); +}, 'Set animation current time to a valid value while playing.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.25 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + + await animation.ready; + animation.currentTime = CSSNumericValue.parse("200%"); + + assert_equals(animation.playState, "finished"); + assert_percents_equal(animation.currentTime, 200, + "Animation current time should be equal to the set value." + ); +}, 'Set animation current time to a value beyond effect end.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.25 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + + await animation.ready; + animation.currentTime = CSSNumericValue.parse("-10%"); + + assert_equals(animation.playState, "running"); + assert_percents_equal(animation.currentTime, -10, + "Animation current time should be equal to the set value." + ); +}, 'Set animation current time to a negative value.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.25 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + + animation.currentTime = CSSNumericValue.parse("30%"); + + assert_equals(animation.playState, "running"); + assert_true(animation.pending); + assert_percents_equal(animation.currentTime, 30); +}, "Setting current time while play pending overrides the current time"); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.25 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + + await animation.ready; + animation.currentTime = CSSNumericValue.parse("33.3%"); + + assert_percents_equal(animation.currentTime, 33.3, + "Animation current time should be equal to the set value." + ); + + // Cancel the animation and play it again, check that current time has reset + // to scroll offset based current time. + animation.cancel(); + animation.play(); + await animation.ready; + + assert_percents_equal(animation.currentTime, animation.timeline.currentTime, + "Animation current time should return to a value matching its" + + " timeline current time after animation is cancelled and played again." + ); +}, 'Setting animation.currentTime then restarting the animation should' + + ' reset the current time.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.25 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + + await animation.ready; + const originalCurrentTime = animation.currentTime.value; + + // Set the current time to something other than where the scroll offset. + animation.currentTime = CSSNumericValue.parse("50%"); + + // Setting current time is internally setting the start time to + // scrollTimeline.currentTime - newAnimationCurrentTime. + // Which results in current time of (timeline.currentTime - start_time). + // This behavior puts the animation in a strange "out of sync" state between + // the scroller and the animation effect, this is currently expected + // behavior. + + const expectedStartTime = originalCurrentTime - animation.currentTime.value; + assert_percents_equal(animation.startTime, expectedStartTime, + "Animation current time should be updated when setting the current time" + + " to a time within the range of the animation."); + + scroller.scrollTop = 0.7 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + assert_percents_equal(animation.startTime, expectedStartTime, + "Animation start time should remain unchanged when the scroller changes" + + " position." + ); + assert_percents_equal(animation.currentTime, + animation.timeline.currentTime.value - animation.startTime.value, + "Animation current time should return to a value equal to" + + " (timeline.currentTime - animation.startTime) after timeline scroll" + + " source has been scrolled." + ); +}, 'Set Animation current time then scroll.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + await animation.ready; + + // Make the timeline inactive. + scroller.style.overflow = 'visible'; + scroller.scrollTop; + await waitForNextFrame(); + + assert_equals(animation.currentTime, null, + 'Current time is unresolved when the timeline is inactive.'); + + animation.currentTime = CSSNumericValue.parse("30%"); + assert_percents_equal(animation.currentTime, 30, + 'Animation current time should be equal to the set value.'); + assert_equals(animation.playState, 'paused', + 'Animation play state is \'paused\' when current time is set and ' + + 'timeline is inactive.'); +}, 'Animation current time and play state are correct when current time is ' + + 'set while the timeline is inactive.'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + await animation.ready; + + // Make the timeline inactive. + scroller.style.overflow = 'visible'; + scroller.scrollTop; + await waitForNextFrame(); + + assert_equals(animation.timeline.currentTime, null, + 'Current time is unresolved when the timeline is inactive.'); + + animation.currentTime = CSSNumericValue.parse("30%"); + assert_percents_equal(animation.currentTime, 30, + 'Animation current time should be equal to the set value.'); + assert_equals(animation.playState, 'paused', + 'Animation play state is \'paused\' when current time is set and ' + + 'timeline is inactive.'); + + // Make the timeline active. + scroller.style.overflow = 'auto'; + scroller.scrollTop; + await waitForNextFrame(); + + assert_percents_equal(animation.timeline.currentTime, 0, + 'Current time is resolved when the timeline is active.'); + assert_percents_equal(animation.currentTime, 30, + 'Animation current time holds the set value.'); + assert_equals(animation.playState, 'paused', + 'Animation holds \'paused\' state.'); +}, 'Animation current time set while the timeline is inactive holds when the ' + + 'timeline becomes active again.'); +</script> +</body> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-playback-rate.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-playback-rate.html new file mode 100644 index 0000000000..e7e96a27e1 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-playback-rate.html @@ -0,0 +1,298 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Setting the playback rate of an animation that is using a ScrollTimeline</title> +<link rel="help" href="https://drafts.csswg.org/web-animations/#setting-the-playback-rate-of-an-animation"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> +.scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; +} +.contents { + height: 1000px; + width: 100%; +} +</style> +<body> +<script> + 'use strict'; + + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + // this forces a layout which results in an active timeline + scroller.scrollTop = 0; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + animation.playbackRate = 0.5; + animation.play(); + await animation.ready; + + assert_percents_equal(animation.currentTime, 0, + 'Zero current time is not affected by playbackRate change.'); + }, 'Zero current time is not affected by playbackRate set while the ' + + 'animation is in idle state.'); + + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + // this forces a layout which results in an active timeline + scroller.scrollTop = 0; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + animation.play(); + await animation.ready; + animation.playbackRate = 0.5; + + assert_percents_equal(animation.currentTime, 0, + 'Zero current time is not affected by playbackRate change.'); + }, 'Zero current time is not affected by playbackRate set while the ' + + 'animation is in play-pending state.'); + + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.2 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + animation.playbackRate = 0.5; + animation.play(); + await animation.ready; + assert_percents_equal(animation.currentTime, 10, + 'Initial current time is scaled by playbackRate change.'); + }, 'Initial current time is scaled by playbackRate set while ' + + 'scroll-linked animation is in running state.'); + + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + const playbackRate = 2; + + scroller.scrollTop = 0.2 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + animation.play(); + await animation.ready; + // Set playback rate while the animation is playing. + animation.playbackRate = playbackRate; + assert_percents_equal(animation.currentTime, 40, + 'The current time is scaled by the playback rate.'); + }, 'The current time is scaled by playbackRate set while the ' + + 'scroll-linked animation is in play state.'); + + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Set playback rate while the animation is in 'idle' state. + animation.playbackRate = 2; + animation.play(); + await animation.ready; + scroller.scrollTop = 0.2 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + assert_percents_equal(animation.currentTime, 40, + 'The current time should increase two times faster ' + + 'than timeline time.'); + }, 'The playback rate set before scroll-linked animation started playing ' + + 'affects the rate of progress of the current time'); + + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + animation.play(); + + await animation.ready; + + animation.playbackRate = 2; + scroller.scrollTop = 0.25 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + assert_percents_equal( + animation.currentTime, + animation.timeline.currentTime.value * animation.playbackRate, + 'The current time should increase two times faster than timeline time'); + }, 'The playback rate affects the rate of progress of the current time' + + ' when scrolling'); + + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + // Setting the current time while play-pending sets the hold time and not + // the start time. currentTime is unaffected by playback rate until no + // longer pending. + animation.currentTime = CSSNumericValue.parse("25%"); + animation.playbackRate = 2; + + assert_equals(animation.playState, "running"); + assert_true(animation.pending); + assert_percents_equal(animation.currentTime, 25); + await animation.ready; + assert_percents_equal(animation.currentTime, 25); + }, 'Setting the playback rate while play-pending does not scale current ' + + 'time.'); + + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.25 * maxScroll; + animation.play(); + await animation.ready; + animation.playbackRate = 2; + + assert_percents_equal(animation.currentTime, 50); + }, 'Setting the playback rate while playing scales current time.'); + + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + + animation.play(); + animation.currentTime = CSSNumericValue.parse("25%"); + await animation.ready; + animation.playbackRate = 2; + + assert_percents_equal(animation.currentTime, 50); + }, 'Setting the playback rate while playing scales the set current time.'); + + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + animation.playbackRate = -1; + scroller.scrollTop = 0.3 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + + await animation.ready; + const expectedCurrentTime = 100 - animation.timeline.currentTime.value; + assert_percents_equal(animation.currentTime, expectedCurrentTime); + }, 'Negative initial playback rate should correctly modify initial current' + + ' time.'); + + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.5 * maxScroll; + animation.play(); + + await animation.ready; + const startingTimelineTime = animation.timeline.currentTime; + const startingCurrentTime = animation.currentTime; + assert_percents_equal(startingCurrentTime, 50); + assert_percents_equal(startingTimelineTime, 50); + + animation.playbackRate = -1; + + scroller.scrollTop = 0.8 * maxScroll; + await waitForNextFrame(); + // -300 = 500 - 800 + + // let timelineDiff = + // startingTimelineTime.value - animation.timeline.currentTime.value; + // // 200 = 500 + (-300) + // let expected = startingCurrentTime.value + timelineDiff; + assert_percents_equal(animation.timeline.currentTime, 80); + assert_percents_equal(animation.currentTime, 20); + + scroller.scrollTop = 0.2 * maxScroll; + await waitForNextFrame(); + // // 300 = 500 - 200 + // timelineDiff = + // startingTimelineTime.value - animation.timeline.currentTime.value; + // // 800 = 500 + 300 + // expected = startingCurrentTime.value + timelineDiff; + assert_percents_equal(animation.timeline.currentTime, 20); + assert_percents_equal(animation.currentTime, 80); + }, 'Reversing the playback rate while playing correctly impacts current' + + ' time during future scrolls'); + + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + animation.playbackRate = 0; + scroller.scrollTop = 0.3 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + + await animation.ready; + assert_percents_equal(animation.currentTime, 0); + }, 'Zero initial playback rate should correctly modify initial current' + + ' time.'); + + promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.2 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + + await animation.ready; + assert_percents_equal(animation.currentTime, 20); + animation.playbackRate = 0; + scroller.scrollTop = 0.5 * maxScroll; + await waitForNextFrame(); + + // Ensure that current time does not change. + assert_percents_equal(animation.timeline.currentTime, 50); + assert_percents_equal(animation.currentTime, 0); + }, 'Setting a zero playback rate while running preserves the start time'); + + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.2 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + + await animation.ready; + assert_percents_equal(animation.timeline.currentTime, 20); + assert_percents_equal(animation.currentTime, 20); + animation.startTime = animation.currentTime; + // timeline current time [0%, 100%] --> animation current time [-20%, 80%]. + assert_percents_equal(animation.currentTime, 0); + + animation.playbackRate = -1; + // timeline current time [0%, 100%] --> animation current time [80%, -20%]. + // timeline @ 20% --> animation current time @ (20% - 80%) * (-1) = 60%. + assert_percents_equal(animation.timeline.currentTime, 20); + assert_percents_equal(animation.currentTime, 60); + }, 'Reversing an animation with non-boundary aligned start time ' + + 'symmetrically adjusts the start time'); + +</script> +</body> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-start-time.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-start-time.html new file mode 100644 index 0000000000..aae1849565 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-start-time.html @@ -0,0 +1,401 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Setting the start time of scroll animation</title> +<link rel="help" href="https://drafts.csswg.org/web-animations/#setting-the-start-time-of-an-animation"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> +.scroller { + overflow: auto; + height: 200px; + width: 100px; + will-change: transform; +} + +.contents { + height: 1000px; + width: 100%; +} +</style> +<body> +<div id="log"></div> +<script> +'use strict'; + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + assert_throws_dom('NotSupportedError', () => { + animation.startTime = CSSNumericValue.parse("300"); + }); + assert_throws_dom('NotSupportedError', () => { + animation.startTime = CSSNumericValue.parse("300ms"); + }); + assert_throws_dom('NotSupportedError', () => { + animation.startTime = CSSNumericValue.parse("0.3s"); + }); +}, 'Setting the start time to an absolute time value throws exception'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.2 * maxScroll; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + // So long as a hold time is set, querying the current time will return + // the hold time. + + // Since the start time is unresolved at this point, setting the current time + // will set the hold time + animation.currentTime = CSSNumericValue.parse("30%"); + assert_equals(animation.startTime, null, 'The start time stays unresolved'); + assert_percents_equal(animation.currentTime, 30, + 'The current time is calculated from the hold time'); + + // If we set the start time, however, we should clear the hold time. + animation.startTime = CSSNumericValue.parse("0%"); + assert_percents_equal(animation.startTime, 0, + 'The start time is set to the requested value'); + assert_percents_equal(animation.currentTime, 20, + 'The current time is calculated from the start time, ' + + 'not the hold time'); + // Sanity check + assert_equals(animation.playState, 'running', + 'Animation reports it is running after setting a resolved ' + + 'start time'); +}, 'Setting the start time clears the hold time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive'); + + // So long as a hold time is set, querying the current time will return + // the hold time. + + // Since the start time is unresolved at this point, setting the current time + // will set the hold time + animation.currentTime = CSSNumericValue.parse("30%"); + assert_equals(animation.startTime, null, 'The start time stays unresolved'); + assert_percents_equal(animation.currentTime, 30, + 'The current time is calculated from the hold time'); + + // If we set the start time, however, we should clear the hold time. + animation.startTime = CSSNumericValue.parse("0%"); + assert_percents_equal(animation.startTime, 0, + 'The start time is set to the requested value'); + assert_equals(animation.currentTime, null, + 'The current time is calculated from the start time, not' + + ' the hold time'); + // Sanity check + assert_equals(animation.playState, 'running', + 'Animation reports it is running after setting a resolved ' + + 'start time'); +}, 'Setting the start time clears the hold time when the timeline is inactive'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.2 * maxScroll; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + // Set up a running animation (i.e. both start time and current time + // are resolved). + animation.startTime = CSSNumericValue.parse("5%"); + assert_equals(animation.playState, 'running'); + assert_percents_equal(animation.startTime, 5, + 'The start time is set to the requested value'); + assert_percents_equal(animation.currentTime, 15, + 'Current time is resolved for a running animation'); + + // Clear start time + animation.startTime = null; + assert_equals(animation.startTime, null, + 'The start time is set to the requested value'); + assert_percents_equal(animation.currentTime, 15, + 'Hold time is set after start time is made unresolved'); + assert_equals(animation.playState, 'paused', + 'Animation reports it is paused after setting an unresolved' + + ' start time'); +}, 'Setting an unresolved start time sets the hold time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive'); + + // Set up a running animation (i.e. both start time and current time + // are resolved). + animation.startTime = CSSNumericValue.parse("5%"); + assert_equals(animation.playState, 'running'); + assert_percents_equal(animation.startTime, 5, + 'The start time is set to the requested value'); + assert_equals(animation.currentTime, null, + 'Current time is unresolved for a running animation when the ' + + 'timeline is inactive'); + + // Clear start time + animation.startTime = null; + assert_equals(animation.startTime, null, + 'The start time is set to the requested value'); + assert_equals(animation.currentTime, null, + 'Hold time is set to unresolved after start time is made ' + + 'unresolved'); + assert_equals(animation.playState, 'idle', + 'Animation reports it is idle after setting an unresolved' + + ' start time'); +}, 'Setting an unresolved start time sets the hold time to unresolved when ' + + 'the timeline is inactive'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + let readyPromiseCallbackCalled = false; + animation.ready.then(() => { readyPromiseCallbackCalled = true; } ); + + // Put the animation in the play-pending state + animation.play(); + + // Sanity check + assert_true(animation.pending && animation.playState === 'running', + 'Animation is in play-pending state'); + + // Setting the start time should resolve the 'ready' promise, i.e. + // it should schedule a microtask to run the promise callbacks. + animation.startTime = CSSNumericValue.parse("10%"); + assert_percents_equal(animation.startTime, 10, + 'The start time is set to the requested value'); + assert_false(readyPromiseCallbackCalled, + 'Ready promise callback is not called synchronously'); + + // If we schedule another microtask then it should run immediately after + // the ready promise resolution microtask. + await Promise.resolve(); + assert_true(readyPromiseCallbackCalled, + 'Ready promise callback called after setting startTime'); +}, 'Setting the start time resolves a pending ready promise'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + // Make the scroll timeline inactive. + scroller.style.overflow = 'visible'; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + assert_equals(animation.timeline.currentTime, null, + 'Sanity check the timeline is inactive'); + + let readyPromiseCallbackCalled = false; + animation.ready.then(() => { readyPromiseCallbackCalled = true; } ); + + // Put the animation in the play-pending state + animation.play(); + + // Sanity check + assert_true(animation.pending && animation.playState === 'running', + 'Animation is in play-pending state'); + + // Setting the start time should resolve the 'ready' promise, i.e. + // it should schedule a microtask to run the promise callbacks. + animation.startTime = CSSNumericValue.parse("10%"); + assert_percents_equal(animation.startTime, 10, + 'The start time is set to the requested value'); + assert_false(readyPromiseCallbackCalled, + 'Ready promise callback is not called synchronously'); + + // If we schedule another microtask then it should run immediately after + // the ready promise resolution microtask. + await Promise.resolve(); + assert_true(readyPromiseCallbackCalled, + 'Ready promise callback called after setting startTime'); +}, 'Setting the start time resolves a pending ready promise when the timeline' + + 'is inactive'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + // Put the animation in the play-pending state + animation.play(); + + // Sanity check + assert_true(animation.pending, 'Animation is pending'); + assert_equals(animation.playState, 'running', 'Animation is play-pending'); + assert_equals(animation.startTime, null, 'Start time is unresolved'); + + // Setting start time should cancel the pending task. + animation.startTime = null; + assert_false(animation.pending, 'Animation is no longer pending'); + assert_equals(animation.playState, 'idle', 'Animation is idle'); +}, 'Setting an unresolved start time on a play-pending animation makes it' + + ' idle'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + + // Set start time such that the current time is past the end time + animation.startTime = CSSNumericValue.parse("-110%"); + assert_percents_equal(animation.startTime, -110, + 'The start time is set to the requested value'); + assert_equals(animation.playState, 'finished', + 'Seeked to finished state using the startTime'); + + // If the 'did seek' flag is true, the current time should be greater than + // the effect end. + assert_greater_than(animation.currentTime.value, + animation.effect.getComputedTiming().endTime.value, + 'Setting the start time updated the finished state with' + + ' the \'did seek\' flag set to true'); + + // Furthermore, that time should persist if we have correctly updated + // the hold time + const finishedCurrentTime = animation.currentTime; + await waitForNextFrame(); + assert_percents_equal(animation.currentTime, finishedCurrentTime, + 'Current time does not change after seeking past the ' + + 'effect end time by setting the current time'); +}, 'Setting the start time updates the finished state'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + + await animation.ready; + assert_equals(animation.playState, 'running'); + + // Setting the start time updates the finished state. The hold time is not + // constrained by the effect end time. + animation.startTime = CSSNumericValue.parse("-110%"); + assert_equals(animation.playState, 'finished'); + + assert_percents_equal(animation.currentTime, 110); +}, 'Setting the start time on a running animation updates the play state'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + await animation.ready; + + // Setting the start time updates the finished state. The hold time is not + // constrained by the normal range of the animation time. + animation.currentTime = CSSNumericValue.parse("100%"); + assert_equals(animation.playState, 'finished', 'Animation is finished'); + animation.playbackRate = -1; + assert_equals(animation.playState, 'running', 'Animation is running'); + animation.startTime = CSSNumericValue.parse("-200%"); + assert_equals(animation.playState, 'finished', 'Animation is finished'); + assert_percents_equal(animation.currentTime, -200); +}, 'Setting the start time on a reverse running animation updates the play ' + + 'state'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + let readyPromiseCallbackCalled = false; + animation.ready.then(() => { readyPromiseCallbackCalled = true; } ); + animation.pause(); + + // Sanity check + assert_true(animation.pending && animation.playState === 'paused', + 'Animation is in pause-pending state'); + + // Setting the start time should resolve the 'ready' promise although + // the resolution callbacks when be run in a separate microtask. + animation.startTime = null; + assert_false(readyPromiseCallbackCalled, + 'Ready promise callback is not called synchronously'); + + await Promise.resolve(); + assert_true(readyPromiseCallbackCalled, + 'Ready promise callback called after setting startTime'); +}, 'Setting the start time resolves a pending pause task'); + +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.play(); + + // We should be play-pending now + assert_true(anim.pending); + assert_equals(anim.playState, 'running'); + + // Apply a pending playback rate + anim.updatePlaybackRate(2); + assert_equals(anim.playbackRate, 1); + assert_true(anim.pending); + + // Setting the start time should apply the pending playback rate + anim.startTime = CSSNumericValue.parse( + anim.timeline.currentTime.value - 2500 + "%"); + assert_equals(anim.playbackRate, 2); + assert_false(anim.pending); + + // Sanity check that the start time is preserved and current time is + // calculated using the new playback rate + assert_percents_equal(anim.startTime, + anim.timeline.currentTime.value - 2500); + assert_percents_equal(anim.currentTime, 5000); +}, 'Setting the start time of a play-pending animation applies a pending ' + + 'playback rate'); + +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + anim.play(); + await anim.ready; + + // We should be running now + assert_false(anim.pending); + assert_equals(anim.playState, 'running'); + + // Apply a pending playback rate + anim.updatePlaybackRate(2); + assert_equals(anim.playbackRate, 1); + assert_true(anim.pending); + + // Setting the start time should apply the pending playback rate + anim.startTime = CSSNumericValue.parse( + anim.timeline.currentTime.value - 25 + "%"); + assert_equals(anim.playbackRate, 2); + assert_false(anim.pending); + + // Sanity check that the start time is preserved and current time is + // calculated using the new playback rate + assert_percents_equal(anim.startTime, + anim.timeline.currentTime.value - 25); + assert_percents_equal(anim.currentTime, 50); +}, 'Setting the start time of a playing animation applies a pending playback ' + + 'rate'); +</script> +</body> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-timeline.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-timeline.tentative.html new file mode 100644 index 0000000000..5813de60fa --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-timeline.tentative.html @@ -0,0 +1,429 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Setting the timeline of scroll animation</title> +<link rel="help" + href="https://drafts.csswg.org/web-animations-1/#setting-the-timeline"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> + .scroller { + overflow-x: hidden; + overflow-y: auto; + height: 200px; + width: 100px; + will-change: transform; + } + .contents { + /* The height is set to align scrolling in pixels with logical time in ms */ + height: 1200px; + width: 100%; + } + @keyframes anim { + from { opacity: 0; } + to { opacity: 1; } + } + .anim { + animation: anim 1s paused linear; + } + #target { + height: 100px; + width: 100px; + background-color: green; + margin-top: -1000px; + } +</style> +<body> +<script> +'use strict'; + +function createAnimation(t) { + const elem = createDiv(t); + const animation = elem.animate({ opacity: [1, 0] }, 1000); + return animation; +} + +function createPausedCssAnimation(t) { + const elem = createDiv(t); + elem.classList.add('anim'); + return elem.getAnimations()[0]; +} + +function updateScrollPosition(timeline, offset) { + const scroller = timeline.source; + assert_true(!!scroller, 'source is resolved'); + scroller.scrollTop = offset; + // Wait for new animation frame which allows the timeline to compute new + // current time. + return waitForNextFrame(); +} + +function assert_timeline_current_time(animation, timeline_current_time) { + if (animation.currentTime instanceof CSSUnitValue){ + assert_percents_equal(animation.timeline.currentTime, timeline_current_time, + `Timeline's currentTime aligns with the scroll ` + + `position even when paused`); + } + else { + assert_times_equal(animation.timeline.currentTime, timeline_current_time, + `Timeline's currentTime aligns with the scroll ` + + `position even when paused`); + } +} + +function assert_scroll_synced_times(animation, timeline_current_time, + animation_current_time) { + assert_timeline_current_time(animation, timeline_current_time); + if (animation.currentTime instanceof CSSUnitValue){ + assert_percents_equal(animation.currentTime, animation_current_time, + `Animation's currentTime aligns with the scroll position`); + } + else { + assert_times_equal(animation.currentTime, animation_current_time, + `Animation's currentTime aligns with the scroll position`); + } +} + +function assert_paused_times(animation, timeline_current_time, + animation_current_time) { + assert_timeline_current_time(animation, timeline_current_time); + if (animation.currentTime instanceof CSSUnitValue){ + assert_percents_equal(animation.currentTime, animation_current_time, + `Animation's currentTime is fixed while paused`); + } + else { + assert_times_equal(animation.currentTime, animation_current_time, + `Animation's currentTime is fixed while paused`); + } +} + +function createViewTimeline(t) { + const parent = document.querySelector('.scroller'); + const elem = document.createElement('div'); + elem.id = 'target'; + t.add_cleanup(() => { + elem.remove(); + }); + parent.appendChild(elem); + return new ViewTimeline({ subject: elem }); +} + +promise_test(async t => { + const scrollTimeline = createScrollTimeline(t); + await updateScrollPosition(scrollTimeline, 100); + + const animation = createAnimation(t); + animation.timeline = scrollTimeline; + assert_true(animation.pending); + await animation.ready; + + assert_equals(animation.playState, 'running'); + assert_scroll_synced_times(animation, 10, 10); +}, 'Setting a scroll timeline on a play-pending animation synchronizes ' + + 'currentTime of the animation with the scroll position.'); + +promise_test(async t => { + const scrollTimeline = createScrollTimeline(t); + await updateScrollPosition(scrollTimeline, 100); + + const animation = createAnimation(t); + animation.pause(); + animation.timeline = scrollTimeline; + assert_true(animation.pending); + await animation.ready; + + assert_equals(animation.playState, 'paused'); + assert_paused_times(animation, 10, 0); + + await updateScrollPosition(animation.timeline, 200); + + assert_equals(animation.playState, 'paused'); + assert_paused_times(animation, 20, 0); + + animation.play(); + await animation.ready; + + assert_scroll_synced_times(animation, 20, 20); +}, 'Setting a scroll timeline on a pause-pending animation fixes the ' + + 'currentTime of the animation based on the scroll position once resumed'); + +promise_test(async t => { + const scrollTimeline = createScrollTimeline(t); + await updateScrollPosition(scrollTimeline, 100); + + const animation = createAnimation(t); + animation.reverse(); + animation.timeline = scrollTimeline; + await animation.ready; + + assert_equals(animation.playState, 'running'); + assert_scroll_synced_times(animation, 10, 90); +}, 'Setting a scroll timeline on a reversed play-pending animation ' + + 'synchronizes the currentTime of the animation with the scroll ' + + 'position.'); + +promise_test(async t => { + const scrollTimeline = createScrollTimeline(t); + await updateScrollPosition(scrollTimeline, 100); + + const animation = createAnimation(t); + await animation.ready; + + animation.timeline = scrollTimeline; + assert_true(animation.pending); + assert_equals(animation.playState, 'running'); + await animation.ready; + assert_scroll_synced_times(animation, 10, 10); +}, 'Setting a scroll timeline on a running animation synchronizes the ' + + 'currentTime of the animation with the scroll position.'); + +promise_test(async t => { + const scrollTimeline = createScrollTimeline(t); + await updateScrollPosition(scrollTimeline, 100); + + const animation = createAnimation(t); + animation.pause(); + await animation.ready; + + animation.timeline = scrollTimeline; + assert_false(animation.pending); + assert_equals(animation.playState, 'paused'); + assert_paused_times(animation, 10, 0); + + animation.play(); + await animation.ready; + + assert_scroll_synced_times(animation, 10, 10); +}, 'Setting a scroll timeline on a paused animation fixes the currentTime of ' + + 'the animation based on the scroll position when resumed'); + +promise_test(async t => { + const scrollTimeline = createScrollTimeline(t); + await updateScrollPosition(scrollTimeline, 100); + + const animation = createAnimation(t); + animation.reverse(); + animation.pause(); + await animation.ready; + + animation.timeline = scrollTimeline; + assert_false(animation.pending); + assert_equals(animation.playState, 'paused'); + assert_paused_times(animation, 10, 100); + + animation.play(); + await animation.ready; + + assert_scroll_synced_times(animation, 10, 90); +}, 'Setting a scroll timeline on a reversed paused animation ' + + 'fixes the currentTime of the animation based on the scroll ' + + 'position when resumed'); + +promise_test(async t => { + const animation = createAnimation(t); + const scrollTimeline = createScrollTimeline(t); + animation.timeline = scrollTimeline; + await animation.ready; + await updateScrollPosition(scrollTimeline, 100); + + animation.timeline = document.timeline; + assert_times_equal(animation.currentTime, 100); +}, 'Transitioning from a scroll timeline to a document timeline on a running ' + + 'animation preserves currentTime'); + +promise_test(async t => { + const animation = createAnimation(t); + const scrollTimeline = createScrollTimeline(t); + animation.timeline = scrollTimeline; + await animation.ready; + await updateScrollPosition(scrollTimeline, 100); + + animation.pause(); + animation.timeline = document.timeline; + + await animation.ready; + assert_times_equal(animation.currentTime, 100); +}, 'Transitioning from a scroll timeline to a document timeline on a ' + + 'pause-pending animation preserves currentTime'); + +promise_test(async t => { + const animation = createAnimation(t); + const scrollTimeline = createScrollTimeline(t); + animation.timeline = scrollTimeline; + + animation.reverse(); + await animation.ready; + await updateScrollPosition(scrollTimeline, 100); + + animation.pause(); + await animation.ready; + + assert_scroll_synced_times(animation, 10, 90); + + animation.timeline = document.timeline; + assert_false(animation.pending); + assert_equals(animation.playState, 'paused'); + assert_times_equal(animation.currentTime, 900); +}, 'Transition from a scroll timeline to a document timeline on a reversed ' + + 'paused animation maintains correct currentTime'); + +promise_test(async t => { + const animation = createAnimation(t); + const scrollTimeline = createScrollTimeline(t); + animation.timeline = scrollTimeline; + await animation.ready; + await updateScrollPosition(scrollTimeline, 100); + + const progress = animation.currentTime.value / 100; + const duration = animation.effect.getTiming().duration; + animation.timeline = null; + + const expectedCurrentTime = progress * duration; + assert_times_equal(animation.currentTime, expectedCurrentTime); +}, 'Transitioning from a scroll timeline to a null timeline on a running ' + + 'animation preserves current progress.'); + +promise_test(async t => { + const keyframeEfect = new KeyframeEffect(createDiv(t), + { opacity: [0, 1] }, + 1000); + const animation = new Animation(keyframeEfect, null); + animation.startTime = 0; + assert_equals(animation.playState, 'running'); + + const scrollTimeline = createScrollTimeline(t); + await updateScrollPosition(scrollTimeline, 100); + + animation.timeline = scrollTimeline; + assert_equals(animation.playState, 'running'); + await animation.ready; + + assert_percents_equal(animation.currentTime, 10); +}, 'Switching from a null timeline to a scroll timeline on an animation with ' + + 'a resolved start time preserved the play state'); + +promise_test(async t => { + const firstScrollTimeline = createScrollTimeline(t); + await updateScrollPosition(firstScrollTimeline, 100); + + const secondScrollTimeline = createScrollTimeline(t); + await updateScrollPosition(secondScrollTimeline, 200); + + const animation = createAnimation(t); + animation.timeline = firstScrollTimeline; + await animation.ready; + assert_percents_equal(animation.currentTime, 10); + + animation.timeline = secondScrollTimeline; + await animation.ready; + + assert_percents_equal(animation.currentTime, 20); +}, 'Switching from one scroll timeline to another updates currentTime'); + +promise_test(async t => { + const scrollTimeline = createScrollTimeline(t); + await updateScrollPosition(scrollTimeline, 100); + + const animation = createPausedCssAnimation(t); + animation.timeline = scrollTimeline; + await animation.ready; + assert_equals(animation.playState, 'paused'); + assert_percents_equal(animation.currentTime, 0); + + const target = animation.effect.target; + target.style.animationPlayState = 'running'; + await animation.ready; + + assert_percents_equal(animation.currentTime, 10); +}, 'Switching from a document timeline to a scroll timeline updates ' + + 'currentTime when unpaused via CSS.'); + +promise_test(async t => { + const scrollTimeline = createScrollTimeline(t); + await updateScrollPosition(scrollTimeline, 100); + + const animation = createAnimation(t); + animation.pause(); + animation.currentTime = 500; // 50% + animation.timeline = scrollTimeline; + await animation.ready; + assert_percents_equal(animation.currentTime, 50); + + animation.play(); + await animation.ready; + assert_percents_equal(animation.currentTime, 10); +}, 'Switching from a document timeline to a scroll timeline and updating ' + + 'currentTime preserves the progress while paused.'); + +promise_test(async t => { + const elem = createDiv(t); + const animation = elem.animate(null, Infinity); + await animation.ready; + + animation.timeline = new ScrollTimeline(); + let timing = animation.effect.getComputedTiming(); + assert_percents_equal(timing.endTime, 100); + assert_percents_equal(timing.activeDuration, 100); + assert_percents_equal(timing.duration, 100); + + animation.effect.updateTiming({ iterations: 2 }); + timing = animation.effect.getComputedTiming(); + assert_percents_equal(timing.endTime, 100); + assert_percents_equal(timing.activeDuration, 100); + assert_percents_equal(timing.duration, 50); + + // Blink implementation does not permit setting an infinite number of + // iterations on a scroll-linked animation. Workaround by temporarily + // switching back to a document timeline. + animation.timeline = document.timeline; + animation.effect.updateTiming({ iterations: Infinity }); + animation.timeline = new ScrollTimeline(); + timing = animation.effect.getComputedTiming(); + // Having an infinite number of iterations with a finite timeline results in + // each iteration having zero duration. + assert_percents_equal(timing.duration, 0); + // If either the iteration duration or iteration count is zero, the active + // duration is always zero. + assert_percents_equal(timing.activeDuration, 0); + assert_percents_equal(timing.endTime, 0); + +}, 'Switching from a document timeline to a scroll timeline on an infinite ' + + 'duration animation.'); + + +promise_test(async t => { + const scrollTimeline = createScrollTimeline(t); + const view_timeline = createViewTimeline(t); + await updateScrollPosition(scrollTimeline, 100); + const animation = createAnimation(t); + animation.timeline = scrollTimeline; + // Range name is ignored while attached to a non-view scroll-timeline. + // Offsets are still applied to the scroll-timeline. + animation.rangeStart = { rangeName: 'contain', offset: CSS.percent(10) }; + animation.rangeEnd = { rangeName: 'contain', offset: CSS.percent(90) }; + await animation.ready; + + assert_scroll_synced_times(animation, 10, 0); + assert_percents_equal(animation.startTime, 10); + + animation.timeline = view_timeline; + assert_true(animation.pending); + await animation.ready; + + // Cover range is [0px, 300px] + // Contain range is [100px, 200px] + // start time = (contain 10% pos - cover start pos) / cover range * 100% + const expected_start_time = 110 / 300 * 100; + // timeline time = (scroll pos - cover start pos) / cover range * 100% + const expected_timeline_time = 100 / 300 * 100; + // current time = timeline time - start time. + const expected_current_time = expected_timeline_time - expected_start_time; + + assert_percents_equal(animation.startTime, expected_start_time); + assert_percents_equal(animation.timeline.currentTime, expected_timeline_time); + assert_percents_equal(animation.currentTime, expected_current_time); +}, 'Changing from a scroll-timeline to a view-timeline updates start time.'); + +</script> +</body> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/source-quirks-mode.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/source-quirks-mode.html new file mode 100644 index 0000000000..17e95a0519 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/source-quirks-mode.html @@ -0,0 +1,36 @@ +<!-- Quirks mode --> +<html> +<head> + <title>ScrollTimeline default source in quirks mode</title> + <link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#dom-scrolltimeline-scrolltimeline"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <style> + /* This is just to make it possible for #body1 to be + "potentially scrollable". + + https://drafts.csswg.org/cssom-view/#potentially-scrollable */ + html { + overflow: hidden; + } + </style> +</head> +<body id=body1></body> +<script> +test(() => { + try { + assert_equals(document.scrollingElement.id, 'body1'); + assert_equals(new ScrollTimeline({}).source, body1); + + // Make #body1 "potentially scrollable". This causes the scrollingElement + // of the document to become null. + // + // https://drafts.csswg.org/cssom-view/#dom-document-scrollingelement + body1.style = 'overflow:scroll'; + assert_equals(new ScrollTimeline({}).source, null); + } finally { + body1.style = ''; + } +}, 'Style of <body> is reflected in source attribute in quirks mode'); +</script> +</html> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/testcommon.js b/testing/web-platform/tests/scroll-animations/scroll-timelines/testcommon.js new file mode 100644 index 0000000000..97e81f494c --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/testcommon.js @@ -0,0 +1,124 @@ +'use strict'; + +// Builds a generic structure that looks like: +// +// <div class="scroller"> // 100x100 viewport +// <div class="contents"></div> // 500x500 +// </div> +// +// The |scrollerOverrides| and |contentOverrides| parameters are maps which +// are applied to the scroller and contents style after basic setup. +// +// Appends the outer 'scroller' element to the document body, and returns it. +function setupScrollTimelineTest( + scrollerOverrides = new Map(), contentOverrides = new Map()) { + let scroller = document.createElement('div'); + scroller.style.width = '100px'; + scroller.style.height = '100px'; + // Hide the scrollbars, but maintain the ability to scroll. This setting + // ensures that variability in scrollbar sizing does not contribute to test + // failures or flakes. + scroller.style.overflow = 'hidden'; + for (const [key, value] of scrollerOverrides) { + scroller.style[key] = value; + } + + let contents = document.createElement('div'); + contents.style.width = '500px'; + contents.style.height = '500px'; + for (const [key, value] of contentOverrides) { + contents.style[key] = value; + } + + scroller.appendChild(contents); + document.body.appendChild(scroller); + return scroller; +} + +// Helper method to calculate the current time, implementing only step 5 of +// https://wicg.github.io/scroll-animations/#current-time-algorithm +function calculateCurrentTime( + currentScrollOffset, startScrollOffset, endScrollOffset) { + return ((currentScrollOffset - startScrollOffset) / + (endScrollOffset - startScrollOffset)) * + 100; +} + +function createScroller(test) { + var scroller = createDiv(test); + scroller.innerHTML = "<div class='contents'></div>"; + scroller.classList.add('scroller'); + // Trigger layout run. + scroller.scrollTop; + return scroller; +} + +function createScrollerWithStartAndEnd(test, orientationClass = 'vertical') { + var scroller = createDiv(test); + scroller.innerHTML = + `<div class='contents'> + <div id='start'></div> + <div id='end'></div> + </div>`; + scroller.classList.add('scroller'); + scroller.classList.add(orientationClass); + + return scroller; +} + +function createScrollTimeline(test, options) { + options = options || { + source: createScroller(test) + } + return new ScrollTimeline(options); +} + +function createScrollLinkedAnimation(test, timeline) { + return createScrollLinkedAnimationWithTiming(test, /* duration in ms*/ 1000, timeline); +} + +function createScrollLinkedAnimationWithTiming(test, timing, timeline) { + if (timeline === undefined) + timeline = createScrollTimeline(test); + const KEYFRAMES = { opacity: [0, 1] }; + return new Animation( + new KeyframeEffect(createDiv(test), KEYFRAMES, timing), timeline); +} + +function assert_approx_equals_or_null(actual, expected, tolerance, name) { + if (actual === null || expected === null){ + assert_equals(actual, expected, name); + } + else { + assert_approx_equals(actual, expected, tolerance, name); + } +} + +function assert_percents_approx_equal(actual, expected, maxScroll, + description) { + // Base the tolerance on being out by up to half a pixel. + const tolerance = 0.5 / maxScroll * 100; + assert_equals(actual.unit, "percent", `'actual' unit type must be ` + + `'percent' for "${description}"`); + assert_true(actual instanceof CSSUnitValue, `'actual' must be of type ` + + `CSSNumberish for "${description}"`); + if (expected instanceof CSSUnitValue){ + // Verify that when the expected in a CSSUnitValue, it is the correct unit + // type + assert_equals(expected.unit, "percent", `'expected' unit type must be ` + + `'percent' for "${description}"`); + assert_approx_equals(actual.value, expected.value, tolerance, + `values do not match for "${description}"`); + } else if (typeof expected, "number"){ + assert_approx_equals(actual.value, expected, tolerance, + `values do not match for "${description}"`); + } +} + +function assert_percents_equal(actual, expected, description) { + // Rough estimate of the default size of the scrollable area based on + // sizes in setupScrollTimelineTest. + const defaultScrollRange = 400; + return assert_percents_approx_equal(actual, expected, defaultScrollRange, + description); +} diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline-cancel-one.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline-cancel-one.html new file mode 100644 index 0000000000..ed8e8337a6 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline-cancel-one.html @@ -0,0 +1,84 @@ +<html class="reftest-wait"> +<title>Scroll timeline shared by two animation, one gets cancelled</title> +<link rel="help" href="https://drafts.csswg.org/scroll-animations/"> +<meta name="assert" content="Cancelling animations should not affect other + animation that is attached to the same timeline."> +<link rel="match" href="animation-ref.html"> + +<script src="/web-animations/testcommon.js"></script> +<script src="/common/reftest-wait.js"></script> + +<style> + #box { + width: 100px; + height: 100px; + background-color: green; + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + } + + #scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; /* force compositing */ + } + + #contents { + height: 1000px; + width: 100%; + } +</style> + +<div id="box"></div> +<div id="covered"></div> +<div id="scroller"> + <div id="contents"><p>Scrolling Contents</p></div> +</div> + +<script> + const box = document.getElementById('box'); + const effect = new KeyframeEffect(box, + [ + { transform: 'translateY(0)', opacity: 1}, + { transform: 'translateY(200px)', opacity: 0} + ], { + duration: 1000, + } + ); + const temporary_effect = new KeyframeEffect(box, + [ + { transform: 'translateX(0)'}, + { transform: 'translateX(200px)'} + ], { + duration: 1000, + } + ); + + const scroller = document.getElementById('scroller'); + const timeline = new ScrollTimeline( + { source: scroller, orientation: 'block' }); + const animation = new Animation(effect, timeline); + const temporary_animation = new Animation(temporary_effect, timeline); + animation.play(); + temporary_animation.play(); + + Promise.all([animation.ready, temporary_animation.ready]).then(() => { + temporary_animation.cancel(); + temporary_animation.ready.then(() => { + waitForAnimationFrames(2).then(_ => { + // Move the scroller to the halfway point. + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.5 * maxScroll; + + waitForAnimationFrames(2).then(_ => { + takeScreenshot(); + }); + }); + }); + }); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline.html new file mode 100644 index 0000000000..de50599fba --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline.html @@ -0,0 +1,79 @@ +<html class="reftest-wait"> +<title>Scroll timeline shared by two animation</title> +<link rel="help" href="https://drafts.csswg.org/scroll-animations/"> +<meta name="assert" content="Should be able to use the same scroll timeline to +drive two animations"> +<link rel="match" href="animation-ref.html"> + +<script src="/web-animations/testcommon.js"></script> +<script src="/common/reftest-wait.js"></script> + +<style> + #box { + width: 100px; + height: 100px; + background-color: green; + } + + #covered { + width: 100px; + height: 100px; + background-color: red; + } + + #scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; /* force compositing */ + } + + #contents { + height: 1000px; + width: 100%; + } +</style> + +<div id="box"></div> +<div id="covered"></div> +<div id="scroller"> + <div id="contents"><p>Scrolling Contents</p></div> +</div> + +<script> + const box = document.getElementById('box'); + const transform_effect = new KeyframeEffect(box, + [ + { transform: 'translateY(0)'}, + { transform: 'translateY(200px)'} + ], { + duration: 1000, + } + ); + const opacity_effect = new KeyframeEffect(box, + [ + { opacity: 1}, + { opacity: 0} + ], { + duration: 1000, + } + ); + + const scroller = document.getElementById('scroller'); + const timeline = new ScrollTimeline( + { source: scroller, orientation: 'block' }); + const transform_animation = new Animation(transform_effect, timeline); + transform_animation.play(); + const opacity_animation = new Animation(opacity_effect, timeline); + opacity_animation.play(); + + Promise.all([transform_animation.ready, opacity_animation.ready]).then(() => { + // Move the scroller to the halfway point. + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = 0.5 * maxScroll; + + waitForAnimationFrames(2).then(_ => { + takeScreenshot(); + }); + }); +</script> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/update-playback-rate.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/update-playback-rate.html new file mode 100644 index 0000000000..10535319fc --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/update-playback-rate.html @@ -0,0 +1,178 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Seamlessly updating the playback rate of an animation</title> +<link rel="help" + href="https://drafts.csswg.org/web-animations-1/#seamlessly-updating-the-playback-rate-of-an-animation"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> +.scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; +} + +.contents { + height: 1000px; + width: 100%; +} +</style> +<body> +<script> +'use strict'; + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + await animation.ready; + + animation.currentTime = CSSNumericValue.parse("50%"); + + animation.updatePlaybackRate(0.5); + await animation.ready; + assert_percents_equal(animation.currentTime, 50, + 'Reducing the playback rate should not change the ' + + 'current time of a playing animation'); + + animation.updatePlaybackRate(2); + await animation.ready; + assert_percents_equal(animation.currentTime, 50, + 'Increasing the playback rate should not change the ' + + 'current time of a playing animation'); +}, 'Updating the playback rate maintains the current time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + await animation.ready; + + assert_false(animation.pending); + animation.updatePlaybackRate(2); + assert_true(animation.pending); +}, 'Updating the playback rate while running makes the animation pending'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + animation.currentTime = CSSNumericValue.parse("50%"); + assert_true(animation.pending); + + animation.updatePlaybackRate(0.5); + + // Check that the hold time is updated as expected + assert_percents_equal(animation.currentTime, 50); + + await animation.ready; + + // As above, check that the currentTime is not calculated by simply + // substituting in the updated playbackRate without updating the startTime. + assert_percents_equal(animation.currentTime, 50, + 'Reducing the playback rate should not change the ' + + 'current time of a play-pending animation'); +}, 'Updating the playback rate on a play-pending animation maintains the ' + + 'current time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + animation.currentTime = CSSNumericValue.parse("50%"); + await animation.ready; + + animation.pause(); + animation.updatePlaybackRate(0.5); + + assert_percents_equal(animation.currentTime, 50); +}, 'Updating the playback rate on a pause-pending animation maintains the ' + + 'current time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + + animation.updatePlaybackRate(2); + animation.updatePlaybackRate(3); + animation.updatePlaybackRate(4); + + assert_equals(animation.playbackRate, 1); + await animation.ready; + + assert_equals(animation.playbackRate, 4); +}, 'If a pending playback rate is set multiple times, the latest wins'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + animation.cancel(); + + animation.updatePlaybackRate(2); + assert_equals(animation.playbackRate, 2); + assert_false(animation.pending); +}, 'In the idle state, the playback rate is applied immediately'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.pause(); + await animation.ready; + + animation.updatePlaybackRate(2); + assert_equals(animation.playbackRate, 2); + assert_false(animation.pending); +}, 'In the paused state, the playback rate is applied immediately'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + animation.finish(); + assert_percents_equal(animation.currentTime, 100); + assert_false(animation.pending); + + animation.updatePlaybackRate(2); + assert_equals(animation.playbackRate, 2); + assert_percents_equal(animation.currentTime, 100); + assert_false(animation.pending); +}, 'Updating the playback rate on a finished animation maintains the current ' + + 'time'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + animation.play(); + animation.finish(); + assert_percents_equal(animation.currentTime, 100); + assert_false(animation.pending); + + animation.updatePlaybackRate(0); + assert_equals(animation.playbackRate, 0); + assert_percents_equal(animation.currentTime, 100); + assert_false(animation.pending); +}, 'Updating the playback rate to zero on a finished animation maintains the ' + + 'current time'); + +</script> +</body> diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/updating-the-finished-state.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/updating-the-finished-state.html new file mode 100644 index 0000000000..86b52d5aa0 --- /dev/null +++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/updating-the-finished-state.html @@ -0,0 +1,565 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Updating the finished state</title> +<link rel="help" href="https://drafts.csswg.org/web-animations/#updating-the-finished-state"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/web-animations/testcommon.js"></script> +<script src="testcommon.js"></script> +<style> +.scroller { + overflow: auto; + height: 100px; + width: 100px; + will-change: transform; +} + +.contents { + height: 1000px; + width: 100%; +} +</style> +<body> +<script> +'use strict'; + +// -------------------------------------------------------------------- +// +// TESTS FOR UPDATING THE HOLD TIME +// +// -------------------------------------------------------------------- + +// CASE 1: playback rate > 0 and current time >= target effect end +// (Also the start time is resolved and there is pending task) +// Did seek = true +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.play(); + + await anim.ready; + + anim.currentTime = CSS.percent(200); + scroller.scrollTop = 0.7 * maxScroll; + await waitForNextFrame(); + + assert_percents_equal(anim.currentTime, 200, + 'Hold time is set so current time should NOT change'); +}, 'Updating the finished state when seeking past end'); + +// Did seek = false +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.play(); + await anim.ready; + + scroller.scrollTop = maxScroll; + await waitForNextFrame(); + + assert_percents_equal(anim.currentTime, 100, + 'Hold time is set to target end clamping current time'); +}, 'Updating the finished state when playing exactly to end'); + +// Did seek = true +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + await anim.ready; + + anim.currentTime = CSS.percent(100); + scroller.scrollTop = 0.7 * maxScroll; + await waitForNextFrame(); + + assert_percents_equal(anim.currentTime, 100, + 'Hold time is set so current time should NOT change'); +}, 'Updating the finished state when seeking exactly to end'); + + +// CASE 2: playback rate < 0 and current time <= 0 +// (Also the start time is resolved and there is pending task) + +// Did seek = false +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.playbackRate = -1; + anim.play(); // Make sure animation is not initially finished + + await anim.ready; + + // Seek to 1ms before 0 and then wait 1ms + anim.currentTime = CSS.percent(1); + scroller.scrollTop = 0.2 * maxScroll; + await waitForNextFrame(); + + assert_percents_equal(anim.currentTime, 0, + 'Hold time is set to zero clamping current time'); +}, 'Updating the finished state when playing in reverse past zero'); + +// Did seek = true +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.playbackRate = -1; + anim.play(); + + await anim.ready; + + anim.currentTime = CSS.percent(-100); + scroller.scrollTop = 0.2 * maxScroll; + await waitForNextFrame(); + + assert_percents_equal(anim.currentTime, -100, + 'Hold time is set so current time should NOT change'); +}, 'Updating the finished state when seeking a reversed animation past zero'); + +// Did seek = false +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.playbackRate = -1; + anim.play(); + await anim.ready; + + scroller.scrollTop = maxScroll; + await waitForNextFrame(); + + assert_percents_equal(anim.currentTime, 0, + 'Hold time is set to target end clamping current time'); +}, 'Updating the finished state when playing a reversed animation exactly ' + + 'to zero'); + +// Did seek = true +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.playbackRate = -1; + anim.play(); + await anim.ready; + + anim.currentTime = CSS.percent(0); + + scroller.scrollTop = 0.2 * maxScroll; + await waitForNextFrame(); + + assert_percents_equal(anim.currentTime, 0, + 'Hold time is set so current time should NOT change'); +}, 'Updating the finished state when seeking a reversed animation exactly ' + + 'to zero'); + +// CASE 3: playback rate > 0 and current time < target end OR +// playback rate < 0 and current time > 0 +// (Also the start time is resolved and there is pending task) + +// Did seek = true; playback rate > 0 +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.play(); + anim.finish(); + await anim.ready; + assert_percents_equal(anim.startTime, -100); + + anim.currentTime = CSS.percent(50); + // When did seek = true, updating the finished state: (i) updates + // the animation's start time and (ii) clears the hold time. + // We can test both by checking that the currentTime is initially + // updated and then increases. + assert_percents_equal(anim.currentTime, 50, 'Start time is updated'); + assert_percents_equal(anim.startTime, -50); + + scroller.scrollTop = 0.2 * maxScroll; + await waitForNextFrame(); + + assert_percents_equal(anim.currentTime, 70, + 'Hold time is not set so current time should increase'); +}, 'Updating the finished state when seeking before end'); + +// Did seek = false; playback rate < 0 +// +// Unfortunately it is not possible to test this case. We need to have +// a hold time set, a resolved start time, and then perform some +// operation that updates the finished state with did seek set to true. +// +// However, the only situation where this could arrive is when we +// replace the timeline and that procedure is likely to change. For all +// other cases we either have an unresolved start time (e.g. when +// paused), we don't have a set hold time (e.g. regular playback), or +// the current time is zero (and anything that gets us out of that state +// will set did seek = true). + +// Did seek = true; playback rate < 0 +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.play(); + anim.playbackRate = -1; + await anim.ready; + + anim.currentTime = CSS.percent(50); + assert_percents_equal(anim.startTime, 50, 'Start time is updated'); + assert_percents_equal(anim.currentTime, 50, 'Current time is updated'); + + scroller.scrollTop = 0.2 * maxScroll; + await waitForNextFrame(); + + assert_percents_equal(anim.currentTime, 30, + 'Hold time is not set so current time should decrease'); +}, 'Updating the finished state when seeking a reversed animation before end'); + + +// CASE 4: playback rate == 0 + +// current time < 0 +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.play(); + anim.playbackRate = 0; + await anim.ready; + + anim.currentTime = CSS.percent(-100); + + scroller.scrollTop = 0.2 * maxScroll; + await waitForNextFrame(); + + assert_percents_equal(anim.currentTime, -100, + 'Hold time should not be cleared so current time should NOT change'); +}, 'Updating the finished state when playback rate is zero and the current ' + + 'time is less than zero'); + +// current time < target end +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.play(); + + anim.playbackRate = 0; + await anim.ready; + + anim.currentTime = CSS.percent(50); + scroller.scrollTop = 0.2 * maxScroll; + await waitForNextFrame(); + + assert_percents_equal(anim.currentTime, 50, + 'Hold time should not be cleared so current time should NOT change'); +}, 'Updating the finished state when playback rate is zero and the current ' + + 'time is less than end'); + +// current time > target end +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.play(); + anim.playbackRate = 0; + await anim.ready; + + anim.currentTime = CSS.percent(200); + scroller.scrollTop = 0.2 * maxScroll; + await waitForNextFrame(); + + assert_percents_equal(anim.currentTime, 200, + 'Hold time should not be cleared so current time should NOT change'); +}, 'Updating the finished state when playback rate is zero and the current' + + 'time is greater than end'); + +// CASE 5: current time unresolved + +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.play(); + anim.cancel(); + // Trigger a change that will cause the "update the finished state" + // procedure to run. + anim.effect.updateTiming({ duration: 2000 }); + assert_equals(anim.currentTime, null, + 'The animation hold time / start time should not be updated'); + // The "update the finished state" procedure is supposed to run after any + // change to timing, but just in case an implementation defers that, let's + // wait a frame and check that the hold time / start time has still not been + // updated. + await waitForAnimationFrames(1); + + assert_equals(anim.currentTime, null, + 'The animation hold time / start time should not be updated'); +}, 'Updating the finished state when current time is unresolved'); + +// CASE 7: start time unresolved + +// Did seek = true +promise_test(async t => { + const anim = createScrollLinkedAnimation(t); + // Wait for new animation frame which allows the timeline to compute new + // current time. + await waitForNextFrame(); + anim.cancel(); + anim.currentTime = CSS.percent(150); + // Trigger a change that will cause the "update the finished state" + // procedure to run. + anim.currentTime = CSS.percent(50); + assert_percents_equal(anim.currentTime, 50, + 'The animation hold time should not be updated'); + assert_equals(anim.startTime, null, + 'The animation start time should not be updated'); +}, 'Updating the finished state when start time is unresolved and did seek = ' + + 'true'); + +// -------------------------------------------------------------------- +// +// TESTS FOR RUNNING FINISH NOTIFICATION STEPS +// +// -------------------------------------------------------------------- + +function waitForFinishEventAndPromise(animation) { + const eventPromise = new Promise(resolve => { + animation.onfinish = resolve; + }); + return Promise.all([eventPromise, animation.finished]); +} + +promise_test(t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + animation.play(); + animation.onfinish = + t.unreached_func('Seeking to finish should not fire finish event'); + animation.finished.then( + t.unreached_func( + 'Seeking to finish should not resolve finished promise')); + animation.currentTime = CSS.percent(100); + animation.currentTime = CSS.percent(0); + animation.pause(); + scroller.scrollTop = 0.2 * maxScroll; + return waitForAnimationFrames(3); +}, 'Finish notification steps don\'t run when the animation seeks to finish ' + + 'and then seeks back again'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + animation.play(); + await animation.ready; + scroller.scrollTop = maxScroll; + + return waitForFinishEventAndPromise(animation); +}, 'Finish notification steps run when the animation completes normally'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + animation.effect.target = null; + + animation.play(); + await animation.ready; + scroller.scrollTop = maxScroll; + return waitForFinishEventAndPromise(animation); +}, 'Finish notification steps run when an animation without a target effect ' + + 'completes normally'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + await animation.ready; + + animation.currentTime = CSS.percent(101); + return waitForFinishEventAndPromise(animation); +}, 'Finish notification steps run when the animation seeks past finish'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + animation.play(); + await animation.ready; + + // Register for notifications now since once we seek away from being + // finished the 'finished' promise will be replaced. + const finishNotificationSteps = waitForFinishEventAndPromise(animation); + animation.finish(); + animation.currentTime = CSS.percent(0); + animation.pause(); + return finishNotificationSteps; +}, 'Finish notification steps run when the animation completes with ' + + '.finish(), even if we then seek away'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + animation.play(); + scroller.scrollTop = maxScroll; + const initialFinishedPromise = animation.finished; + await animation.finished; + + animation.currentTime = CSS.percent(0); + assert_not_equals(initialFinishedPromise, animation.finished); +}, 'Animation finished promise is replaced after seeking back to start'); + +promise_test(async t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + animation.play(); + + const initialFinishedPromise = animation.finished; + scroller.scrollTop = maxScroll; + await animation.finished; + + scroller.scrollTop = 0; + await waitForNextFrame(); + + animation.play(); + assert_not_equals(initialFinishedPromise, animation.finished); +}, 'Animation finished promise is replaced after replaying from start'); + +async_test(t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + animation.play(); + + animation.onfinish = event => { + scroller.scrollTop = 0; + window.requestAnimationFrame(function() { + window.requestAnimationFrame(function() { + scroller.scrollTop = maxScroll; + }); + }); + animation.onfinish = event => { + t.done(); + }; + }; + scroller.scrollTop = maxScroll; +}, 'Animation finish event is fired again after seeking back to start'); + +async_test(t => { + const animation = createScrollLinkedAnimation(t); + const scroller = animation.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + animation.play(); + + animation.onfinish = event => { + scroller.scrollTop = 0; + window.requestAnimationFrame(function() { + animation.play(); + scroller.scrollTop = maxScroll; + animation.onfinish = event => { + t.done(); + }; + }); + }; + scroller.scrollTop = maxScroll; +}, 'Animation finish event is fired again after replaying from start'); + +async_test(t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + anim.effect.updateTiming({ duration: 800, endDelay: 200}); + + anim.onfinish = t.step_func(event => { + assert_unreached('finish event should not be fired'); + }); + anim.play(); + anim.ready.then(() => { + scroller.scrollTop = 0.9 * maxScroll; + return waitForAnimationFrames(3); + }).then(t.step_func(() => { + t.done(); + })); +}, 'finish event is not fired at the end of the active interval when the ' + + 'endDelay has not expired'); + +async_test(t => { + const anim = createScrollLinkedAnimation(t); + const scroller = anim.timeline.source; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + + anim.effect.updateTiming({ duration: 800, endDelay: 100}); + anim.play(); + anim.ready.then(() => { + scroller.scrollTop = 0.95 * maxScroll; // during endDelay + anim.onfinish = t.step_func(event => { + assert_unreached('onfinish event should not be fired during endDelay'); + }); + return waitForAnimationFrames(2); + }).then(t.step_func(() => { + anim.onfinish = t.step_func(event => { + t.done(); + }); + scroller.scrollTop = maxScroll; + return waitForAnimationFrames(2); + })); +}, 'finish event is fired after the endDelay has expired'); + +</script> +</body> |