mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-14 16:36:18 -08:00
refactor(core): CORE-17 type input events and own interaction lifetimes
This commit is contained in:
1 parent
88aef830df
commit
a842058136
72 files changed
+31156
-1219
No files matched your search
@@ -0,0 +1,125 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core, raf = false) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate((raf) => {
|
||||
window.Artplayer.SCROLL_TIME = 24731
|
||||
window.Artplayer.RESIZE_TIME = 0
|
||||
window.Artplayer.USE_RAF = raf
|
||||
const orientation = new EventTarget()
|
||||
orientation.onchange = null
|
||||
Object.defineProperty(window.screen, 'orientation', { configurable: true, value: orientation })
|
||||
window.createPlayer('/test/pattern.mp4')
|
||||
}, raf)
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: actual animation frames follow playback and stop after destruction inside raf`, async ({ page }) => {
|
||||
await setup(page, core, true)
|
||||
await page.evaluate(() => {
|
||||
window.frameCount = 0
|
||||
window.art.on('raf', () => window.frameCount++)
|
||||
return window.art.play()
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.frameCount)).toBeGreaterThan(2)
|
||||
await page.evaluate(() => window.art.pause())
|
||||
const paused = await page.evaluate(() => window.frameCount)
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
|
||||
expect(await page.evaluate(() => window.frameCount)).toBe(paused)
|
||||
await page.evaluate(() => {
|
||||
window.art.once('raf', () => window.art.destroy())
|
||||
return window.art.play()
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isDestroy)).toBe(true)
|
||||
const destroyed = await page.evaluate(() => window.frameCount)
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
|
||||
expect(await page.evaluate(() => window.frameCount)).toBe(destroyed)
|
||||
})
|
||||
|
||||
test(`${core}: Events keeps its public fields, prototype and detached native methods`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const events = art.events
|
||||
const target = document.createElement('div')
|
||||
let hits = 0
|
||||
const { proxy, hover } = events
|
||||
const dispose = proxy(target, 'example', () => hits++)
|
||||
hover(target, () => hits++, () => hits++)
|
||||
target.dispatchEvent(new Event('example'))
|
||||
target.dispatchEvent(new Event('mouseenter'))
|
||||
target.dispatchEvent(new Event('mouseleave'))
|
||||
events.remove(dispose)
|
||||
target.dispatchEvent(new Event('example'))
|
||||
const result = { fields: Object.keys(events).sort(), prototype: Object.getOwnPropertyNames(Object.getPrototypeOf(events)).sort(), hits }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ fields: ['bindGlobalEvents', 'destroyEvents', 'hover', 'proxy'], prototype: ['constructor', 'destroy', 'hover', 'proxy', 'remove'], hits: 3 })
|
||||
})
|
||||
|
||||
test(`${core}: screen orientation dispatch works when onchange has no assigned handler`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
let resized = 0
|
||||
art.on('resize', () => resized++)
|
||||
window.screen.orientation.dispatchEvent(new Event('change'))
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
art.destroy()
|
||||
return resized
|
||||
})).toBe(core === 'candidate' ? 1 : 0)
|
||||
})
|
||||
|
||||
test(`${core}: destruction releases the scroll throttle reset timer`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const set = window.setTimeout
|
||||
const clear = window.clearTimeout
|
||||
const pending = new Set()
|
||||
window.setTimeout = function (callback, delay, ...args) {
|
||||
const timer = set.call(this, callback, delay, ...args)
|
||||
if (delay === 24731)
|
||||
pending.add(timer)
|
||||
return timer
|
||||
}
|
||||
window.clearTimeout = function (timer) {
|
||||
pending.delete(timer)
|
||||
return clear.call(this, timer)
|
||||
}
|
||||
try {
|
||||
art.emit('window:scroll', new Event('scroll'))
|
||||
const before = pending.size
|
||||
art.destroy()
|
||||
return { before, after: pending.size }
|
||||
}
|
||||
finally {
|
||||
for (const timer of pending)
|
||||
clear.call(window, timer)
|
||||
window.setTimeout = set
|
||||
window.clearTimeout = clear
|
||||
}
|
||||
})).toEqual({ before: 1, after: core === 'candidate' ? 0 : 1 })
|
||||
})
|
||||
|
||||
test(`${core}: view visibility uses the container's current iframe viewport`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const frame = document.createElement('iframe')
|
||||
frame.style.cssText = 'width:200px;height:100px'
|
||||
document.body.append(frame)
|
||||
const container = art.template.$container
|
||||
container.style.cssText = 'position:absolute;top:250px;left:0;width:200px;height:100px'
|
||||
frame.contentDocument.body.append(container)
|
||||
art.events.bindGlobalEvents()
|
||||
const seen = []
|
||||
art.on('view', visible => seen.push(visible))
|
||||
frame.contentWindow.dispatchEvent(new Event('scroll'))
|
||||
art.destroy()
|
||||
frame.remove()
|
||||
return seen
|
||||
})).toEqual([core === 'published'])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core, primeMedia = true) {
|
||||
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android gesture fixture' }))
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
window.events = []
|
||||
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, gesture: true })
|
||||
window.touch = (target, type, points) => {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'touches', { value: points.map(([pageX, pageY, identifier = 1]) => ({ pageX, pageY, clientX: pageX, clientY: pageY, identifier })) })
|
||||
target.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
if (!primeMedia)
|
||||
return
|
||||
await page.evaluate(() => window.art.play())
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime > 0.1)).toBe(true)
|
||||
await page.evaluate(() => window.art.pause())
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused && !window.art.template.$video.seeking)).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.art.currentTime = 2
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => !window.art.template.$video.seeking && Math.abs(window.art.currentTime - 2) < 0.1)).toBe(true)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: a gesture before first play still issues the requested seek`, async ({ page }) => {
|
||||
await setup(page, core, false)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const start = art.currentTime
|
||||
const expected = start + art.duration * 0.1 * art.constructor.TOUCH_MOVE_RATIO
|
||||
const requests = []
|
||||
art.on('seek', (time, requested) => requests.push(requested))
|
||||
window.touch(art.template.$video, 'touchstart', [[100, 100]])
|
||||
window.touch(art.template.$video, 'touchmove', [[100 + art.width * 0.1, 100]])
|
||||
art.destroy()
|
||||
return { count: requests.length, valid: Math.abs(requests[0] - expected) < 0.000001 }
|
||||
})).toEqual({ count: 1, valid: true })
|
||||
})
|
||||
|
||||
test(`${core}: progress touch keeps absolute position and bar-before-seek ordering`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const progress = art.template.$progress
|
||||
const rect = progress.getBoundingClientRect()
|
||||
const calls = []
|
||||
art.on('setBar', (kind, value, event) => {
|
||||
if (event?.type === 'touchstart')
|
||||
calls.push(['bar', value, event])
|
||||
})
|
||||
art.on('seek', (time, requested) => calls.push(['seek', requested]))
|
||||
const event = window.touch(progress, 'touchstart', [[rect.left + progress.clientWidth / 2, rect.top]])
|
||||
window.touch(document, 'touchend', [])
|
||||
return { order: calls.map(item => item[0]), percentage: calls[0][1], identity: calls[0][2] === event, requested: calls[1][1], expected: art.duration / 2 }
|
||||
})
|
||||
expect(result.order).toEqual(['bar', 'seek'])
|
||||
expect(result.percentage).toBeCloseTo(0.5, 6)
|
||||
expect(result.identity).toBe(true)
|
||||
expect(result.requested).toBeCloseTo(result.expected, 6)
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeCloseTo(result.expected, 1)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: destroy during absolute progress touch stops the following seek`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const progress = art.template.$progress
|
||||
const rect = progress.getBoundingClientRect()
|
||||
let seeks = 0
|
||||
art.on('seek', () => seeks++)
|
||||
art.on('setBar', (kind, value, event) => {
|
||||
if (event?.type === 'touchstart')
|
||||
art.destroy()
|
||||
})
|
||||
window.touch(progress, 'touchstart', [[rect.left + progress.clientWidth / 2, rect.top]])
|
||||
return { seeks, destroyed: art.isDestroy }
|
||||
})).toEqual({ seeks: core === 'candidate' ? 0 : 1, destroyed: true })
|
||||
})
|
||||
|
||||
test(`${core}: normal touch drag keeps seek/bar ordering and original touch identity`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const video = art.template.$video
|
||||
const calls = []
|
||||
const start = art.currentTime
|
||||
const expected = start + art.duration * 0.2 * art.constructor.TOUCH_MOVE_RATIO
|
||||
art.on('seek', (time, requested) => calls.push(['seek', requested]))
|
||||
art.on('setBar', (kind, percentage, event) => {
|
||||
if (event?.type === 'touchmove')
|
||||
calls.push(['bar', kind, percentage, event])
|
||||
})
|
||||
window.touch(video, 'touchstart', [[100, 100]])
|
||||
const event = window.touch(video, 'touchmove', [[100 + art.width * 0.2, 100]])
|
||||
window.touch(document, 'touchend', [])
|
||||
return { expected, order: calls.map(item => item[0]), requested: calls[0][1], percentage: calls[1][2], identity: calls[1][3] === event }
|
||||
})
|
||||
expect(result.order).toEqual(['seek', 'bar'])
|
||||
expect(result.identity).toBe(true)
|
||||
expect(result.requested).toBeCloseTo(result.expected, 6)
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeCloseTo(result.expected, 1)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
for (const cause of ['cancel', 'lock', 'multitouch', 'identifier']) {
|
||||
test(`${core}: ${cause} invalidates an in-progress gesture`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate((cause) => {
|
||||
const art = window.art
|
||||
const video = art.template.$video
|
||||
const seeks = []
|
||||
art.on('seek', (time, requested) => seeks.push(requested))
|
||||
window.touch(video, 'touchstart', [[100, 100]])
|
||||
window.touch(video, 'touchmove', [[100 + art.width * 0.1, 100]])
|
||||
if (cause === 'cancel')
|
||||
window.touch(video, 'touchcancel', [])
|
||||
if (cause === 'lock')
|
||||
art.isLock = true
|
||||
if (cause === 'multitouch')
|
||||
window.touch(video, 'touchmove', [[150, 100], [200, 100, 2]])
|
||||
window.touch(video, 'touchmove', [[100 + art.width * 0.3, 100, cause === 'identifier' ? 2 : 1]])
|
||||
art.destroy()
|
||||
return seeks.length
|
||||
}, cause)).toBe(core === 'candidate' ? 1 : 2)
|
||||
})
|
||||
}
|
||||
|
||||
test(`${core}: destroy from a touch seek callback prevents the later progress notification`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const video = art.template.$video
|
||||
let bars = 0
|
||||
art.on('seek', () => art.destroy())
|
||||
art.on('setBar', (kind, value, event) => {
|
||||
if (event?.type === 'touchmove')
|
||||
bars++
|
||||
})
|
||||
window.touch(video, 'touchstart', [[100, 100]])
|
||||
window.touch(video, 'touchmove', [[100 + art.width * 0.1, 100]])
|
||||
return { bars, destroyed: art.isDestroy }
|
||||
})).toEqual({ bars: core === 'candidate' ? 0 : 1, destroyed: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
for (const action of ['rebind', 'destroy']) {
|
||||
test(`candidate: ${action} during native global registration cancels the interrupted binding`, async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate((action) => {
|
||||
const art = window.art
|
||||
const frames = [document.createElement('iframe'), document.createElement('iframe')]
|
||||
frames.forEach(frame => document.body.append(frame))
|
||||
const [interrupted, newest] = frames
|
||||
const target = interrupted.contentDocument
|
||||
const original = target.addEventListener
|
||||
target.addEventListener = function (name, ...args) {
|
||||
if (name === 'click') {
|
||||
if (action === 'destroy')
|
||||
art.destroy()
|
||||
else
|
||||
art.events.bindGlobalEvents({ document: newest.contentDocument, window: newest.contentWindow })
|
||||
}
|
||||
return original.call(this, name, ...args)
|
||||
}
|
||||
const received = []
|
||||
art.on('document:mouseup', event => received.push(event.target === newest.contentDocument ? 'newest' : 'stale'))
|
||||
art.events.bindGlobalEvents({ document: target, window: interrupted.contentWindow })
|
||||
document.dispatchEvent(new Event('mouseup'))
|
||||
for (const frame of frames)
|
||||
frame.contentDocument.dispatchEvent(new frame.contentWindow.Event('mouseup'))
|
||||
if (!art.isDestroy)
|
||||
art.destroy()
|
||||
target.addEventListener = original
|
||||
for (const frame of frames)
|
||||
frame.remove()
|
||||
return { received, remaining: art.events.destroyEvents.size }
|
||||
}, action)).toEqual({ received: action === 'rebind' ? ['newest'] : [], remaining: 0 })
|
||||
})
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: global rebinding forwards original events once and releases the previous document`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const frame = document.createElement('iframe')
|
||||
document.body.append(frame)
|
||||
const next = frame.contentDocument
|
||||
const events = []
|
||||
art.on('document:mouseup', event => events.push(event))
|
||||
const first = new Event('mouseup')
|
||||
document.dispatchEvent(first)
|
||||
const returned = art.events.bindGlobalEvents({ document: next, window: frame.contentWindow })
|
||||
art.events.bindGlobalEvents({ document: next, window: frame.contentWindow })
|
||||
document.dispatchEvent(new Event('mouseup'))
|
||||
const second = new frame.contentWindow.Event('mouseup')
|
||||
next.dispatchEvent(second)
|
||||
art.destroy()
|
||||
next.dispatchEvent(new frame.contentWindow.Event('mouseup'))
|
||||
frame.remove()
|
||||
return { count: events.length, identity: events[0] === first && events[1] === second, returned: typeof returned }
|
||||
})).toEqual({ count: 2, identity: true, returned: 'undefined' })
|
||||
})
|
||||
|
||||
test(`${core}: failed global rebinding keeps the previous document and removes partial new listeners`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const frame = document.createElement('iframe')
|
||||
document.body.append(frame)
|
||||
const next = frame.contentDocument
|
||||
const original = next.addEventListener
|
||||
const failure = new Error('document listener denied')
|
||||
next.addEventListener = function (name, ...args) {
|
||||
if (name === 'keydown')
|
||||
throw failure
|
||||
return original.call(this, name, ...args)
|
||||
}
|
||||
const received = []
|
||||
art.on('document:mouseup', event => received.push(event.target === document ? 'old' : 'new'))
|
||||
let sameError = false
|
||||
try {
|
||||
art.events.bindGlobalEvents({ document: next, window: frame.contentWindow })
|
||||
}
|
||||
catch (error) {
|
||||
sameError = error === failure
|
||||
}
|
||||
next.addEventListener = original
|
||||
document.dispatchEvent(new Event('mouseup'))
|
||||
next.dispatchEvent(new frame.contentWindow.Event('mouseup'))
|
||||
art.destroy()
|
||||
frame.remove()
|
||||
return { received, sameError }
|
||||
})).toEqual({ received: core === 'candidate' ? ['old'] : ['new'], sameError: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: mobile keeps keyboard initialization opt-in through the existing init method`, async ({ page }) => {
|
||||
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android keyboard fixture' }))
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const initial = Object.keys(art.hotkey.keys)
|
||||
let hits = 0
|
||||
art.hotkey.add('KeyK', () => hits++)
|
||||
art.isFocus = true
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK' }))
|
||||
const before = hits
|
||||
art.hotkey.init()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK' }))
|
||||
const result = { initial, before, after: hits }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ initial: [], before: 0, after: 1 })
|
||||
})
|
||||
|
||||
test(`${core}: prototype-named hotkeys do not crash or replace the keys prototype`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const prototype = Object.getPrototypeOf(art.hotkey.keys)
|
||||
const errors = []
|
||||
let hits = 0
|
||||
art.isFocus = true
|
||||
for (const key of ['__proto__', 'constructor', 'toString']) {
|
||||
try {
|
||||
const callback = () => hits++
|
||||
art.hotkey.add(key, callback)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { code: key, cancelable: true }))
|
||||
art.hotkey.remove(key, callback)
|
||||
}
|
||||
catch {
|
||||
errors.push(key)
|
||||
}
|
||||
}
|
||||
const stable = Object.getPrototypeOf(art.hotkey.keys) === prototype
|
||||
art.destroy()
|
||||
return { errors, hits, stable }
|
||||
})).toEqual({ errors: core === 'candidate' ? [] : ['__proto__', 'constructor', 'toString'], hits: core === 'candidate' ? 3 : 0, stable: true })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: native keyboard play/pause, seek, volume and web-fullscreen Escape retain their behavior', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
await page.evaluate(() => {
|
||||
window.art.isFocus = true
|
||||
window.nativeHotkeys = []
|
||||
window.art.on('hotkey', event => window.nativeHotkeys.push(event.code))
|
||||
})
|
||||
await page.keyboard.press('Space')
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.currentTime > 0.1)).toBe(true)
|
||||
await page.keyboard.press('Space')
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused && !window.art.template.$video.seeking)).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.art.currentTime = 1
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => !window.art.template.$video.seeking && Math.abs(window.art.currentTime - 1) < 0.1)).toBe(true)
|
||||
await page.keyboard.press('ArrowRight')
|
||||
expect(await page.evaluate(() => window.nativeHotkeys.at(-1))).toBe('ArrowRight')
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(5)
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$video.seeking)).toBe(false)
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeLessThan(2)
|
||||
const volume = await page.evaluate(() => {
|
||||
window.art.volume = 0.5
|
||||
return window.art.volume
|
||||
})
|
||||
await page.keyboard.press('ArrowUp')
|
||||
expect(await page.evaluate(() => window.art.volume)).toBeGreaterThan(volume)
|
||||
await page.evaluate(() => {
|
||||
window.art.fullscreenWeb = true
|
||||
})
|
||||
await page.keyboard.press('Escape')
|
||||
expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(false)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
for (const kind of ['inherited', 'plaintext', 'select']) {
|
||||
test(`candidate: ${kind} editing and IME composition do not activate player shortcuts`, async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate((kind) => {
|
||||
const art = window.art
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = kind === 'select' ? '<select><option>one</option></select>' : `<div contenteditable="${kind === 'plaintext' ? 'plaintext-only' : 'true'}"><span tabindex="0">edit</span></div>`
|
||||
art.template.$player.append(root)
|
||||
const target = root.querySelector('span,select')
|
||||
target.focus()
|
||||
art.isFocus = true
|
||||
let hits = 0
|
||||
const generic = []
|
||||
art.hotkey.add('KeyK', () => hits++)
|
||||
art.on('keydown', value => generic.push(value.code))
|
||||
const key = new KeyboardEvent('keydown', { code: 'KeyK', cancelable: true, bubbles: true })
|
||||
target.dispatchEvent(key)
|
||||
target.blur()
|
||||
const composition = new KeyboardEvent('keydown', { code: 'KeyK', cancelable: true, bubbles: true, isComposing: true })
|
||||
document.dispatchEvent(composition)
|
||||
art.destroy()
|
||||
return { hits, generic, prevented: [key.defaultPrevented, composition.defaultPrevented] }
|
||||
}, kind)).toEqual({ hits: 0, generic: ['KeyK', 'KeyK'], prevented: [false, false] })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: destroying during a key callback stops remaining keys and notifications', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const calls = []
|
||||
art.isFocus = true
|
||||
art.hotkey.add('KeyK', () => {
|
||||
calls.push('first')
|
||||
art.destroy(false)
|
||||
})
|
||||
art.hotkey.add('KeyK', () => calls.push('second'))
|
||||
art.on('hotkey', () => calls.push('hotkey'))
|
||||
art.on('keydown', () => calls.push('keydown'))
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK' }))
|
||||
art.hotkey.init()
|
||||
art.emit('document:keydown', new KeyboardEvent('keydown', { code: 'KeyK' }))
|
||||
return calls
|
||||
})).toEqual(['first'])
|
||||
})
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: hotkey registration retains chain, array identity, receiver and event order`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.isFocus = true
|
||||
const calls = []
|
||||
const event = new KeyboardEvent('keydown', { code: 'KeyK', bubbles: true, cancelable: true })
|
||||
function callback(value) {
|
||||
calls.push(['callback', this === art, value === event])
|
||||
}
|
||||
const add = art.hotkey.add('KeyK', callback)
|
||||
const list = art.hotkey.keys.KeyK
|
||||
art.hotkey.add('KeyK', callback)
|
||||
art.on('hotkey', value => calls.push(['hotkey', value === event]))
|
||||
art.on('keydown', value => calls.push(['keydown', value === event]))
|
||||
document.dispatchEvent(event)
|
||||
const count = list.length
|
||||
const remove = art.hotkey.remove('KeyK', callback)
|
||||
const result = { chain: add === art.hotkey && remove === art.hotkey, count, retainedArray: list.length === 0, missing: !Object.hasOwn(art.hotkey.keys, 'KeyK'), keysPrototype: Object.getPrototypeOf(art.hotkey.keys) === Object.prototype, prevented: event.defaultPrevented, calls }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ chain: true, count: 1, retainedArray: true, missing: true, keysPrototype: true, prevented: true, calls: [['callback', true, true], ['hotkey', true], ['keydown', true]] })
|
||||
})
|
||||
|
||||
test(`${core}: rebound document input retains typing instead of triggering a hotkey`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
await page.evaluate(() => {
|
||||
const frame = document.createElement('iframe')
|
||||
frame.id = 'keyboard-frame'
|
||||
document.body.append(frame)
|
||||
const doc = frame.contentDocument
|
||||
doc.body.innerHTML = '<input id="editor">'
|
||||
window.art.events.bindGlobalEvents({ document: doc, window: frame.contentWindow })
|
||||
window.hits = 0
|
||||
window.art.hotkey.add('KeyK', () => window.hits++)
|
||||
window.art.isFocus = true
|
||||
})
|
||||
const editor = page.frameLocator('#keyboard-frame').locator('#editor')
|
||||
await editor.focus()
|
||||
await editor.press('k')
|
||||
expect(await page.evaluate(() => window.hits)).toBe(core === 'candidate' ? 0 : 1)
|
||||
await expect(editor).toHaveValue(core === 'candidate' ? 'k' : '')
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: shadow-root input retains typing instead of triggering a hotkey`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
await page.evaluate(() => {
|
||||
const host = document.createElement('div')
|
||||
host.id = 'shadow-editor'
|
||||
window.art.template.$player.append(host)
|
||||
host.attachShadow({ mode: 'open' }).innerHTML = '<input id="editor">'
|
||||
window.hits = 0
|
||||
window.art.hotkey.add('KeyK', () => window.hits++)
|
||||
window.art.isFocus = true
|
||||
})
|
||||
const editor = page.locator('#shadow-editor input')
|
||||
await editor.focus()
|
||||
await editor.press('k')
|
||||
expect(await page.evaluate(() => window.hits)).toBe(core === 'candidate' ? 0 : 1)
|
||||
await expect(editor).toHaveValue(core === 'candidate' ? 'k' : '')
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: repeated hotkey init does not multiply the keyboard subscription`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.isFocus = true
|
||||
let hits = 0
|
||||
art.hotkey.add('KeyK', () => hits++)
|
||||
art.hotkey.init()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK', bubbles: true, cancelable: true }))
|
||||
art.destroy()
|
||||
return hits
|
||||
})).toBe(core === 'candidate' ? 1 : 2)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, mobile = false) {
|
||||
if (mobile)
|
||||
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android closeout fixture' }))
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
await page.evaluate(() => {
|
||||
window.Artplayer.SCROLL_TIME = 0
|
||||
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, gesture: true })
|
||||
window.touch = (target, name, x, y) => {
|
||||
const event = new Event(name, { bubbles: true })
|
||||
Object.defineProperty(event, 'touches', { value: [{ identifier: 1, pageX: x, pageY: y, clientX: x, clientY: y }] })
|
||||
target.dispatchEvent(event)
|
||||
}
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
test('candidate closeout: restoring a removed default hotkey keeps identity and real playback', async ({ page }) => {
|
||||
await setup(page)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const original = art.hotkey.keys.Space[0]
|
||||
art.hotkey.remove('Space', original)
|
||||
const absent = !art.hotkey.keys.Space
|
||||
art.hotkey.init()
|
||||
art.hotkey.init()
|
||||
art.isFocus = true
|
||||
return { absent, count: art.hotkey.keys.Space.length, identity: art.hotkey.keys.Space[0] === original }
|
||||
})).toEqual({ absent: true, count: 1, identity: true })
|
||||
await page.keyboard.press('Space')
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test('candidate closeout: a failed native removal remains retryable and releases its record', async ({ page }) => {
|
||||
await setup(page)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const target = document.createElement('div')
|
||||
const remove = target.removeEventListener
|
||||
const warning = console.warn
|
||||
const error = new Error('temporary removal failure')
|
||||
let deny = true
|
||||
let warnings = 0
|
||||
let hits = 0
|
||||
console.warn = (message, actual) => {
|
||||
if (actual === error)
|
||||
warnings++
|
||||
}
|
||||
target.removeEventListener = function (...args) {
|
||||
if (deny)
|
||||
throw error
|
||||
return remove.apply(this, args)
|
||||
}
|
||||
try {
|
||||
const dispose = art.events.proxy(target, 'example', () => hits++)
|
||||
art.events.remove(dispose)
|
||||
const retained = art.events.destroyEvents.has(dispose)
|
||||
target.dispatchEvent(new Event('example'))
|
||||
deny = false
|
||||
art.events.remove(dispose)
|
||||
target.dispatchEvent(new Event('example'))
|
||||
const released = !art.events.destroyEvents.has(dispose)
|
||||
art.destroy()
|
||||
return { retained, released, warnings, hits }
|
||||
}
|
||||
finally {
|
||||
console.warn = warning
|
||||
target.removeEventListener = remove
|
||||
}
|
||||
})).toEqual({ retained: true, released: true, warnings: 1, hits: 1 })
|
||||
})
|
||||
|
||||
test('candidate closeout: destruction before native add returns cannot leave a late listener', async ({ page }) => {
|
||||
await setup(page)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const target = document.createElement('div')
|
||||
const add = target.addEventListener
|
||||
let hits = 0
|
||||
target.addEventListener = function (...args) {
|
||||
art.destroy()
|
||||
return add.apply(this, args)
|
||||
}
|
||||
art.events.proxy(target, 'example', () => hits++)
|
||||
target.dispatchEvent(new Event('example'))
|
||||
return { hits, remaining: art.events.destroyEvents.size, destroyed: art.isDestroy }
|
||||
})).toEqual({ hits: 0, remaining: 0, destroyed: true })
|
||||
})
|
||||
|
||||
test('candidate closeout: window registration failure restores the previous global binding', async ({ page }) => {
|
||||
await setup(page)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const frame = document.createElement('iframe')
|
||||
document.body.append(frame)
|
||||
const next = frame.contentWindow
|
||||
const add = next.addEventListener
|
||||
const failure = new Error('window registration failed after add')
|
||||
next.addEventListener = function (name, ...args) {
|
||||
add.call(this, name, ...args)
|
||||
if (name === 'scroll')
|
||||
throw failure
|
||||
}
|
||||
const received = []
|
||||
art.on('document:mouseup', event => received.push(event.target === document ? 'old-document' : 'new-document'))
|
||||
art.on('window:scroll', event => received.push(event.target === window ? 'old-window' : 'new-window'))
|
||||
let sameError = false
|
||||
try {
|
||||
art.events.bindGlobalEvents({ document: next.document, window: next })
|
||||
}
|
||||
catch (error) {
|
||||
sameError = error === failure
|
||||
}
|
||||
next.addEventListener = add
|
||||
next.document.dispatchEvent(new next.Event('mouseup'))
|
||||
next.dispatchEvent(new next.Event('scroll'))
|
||||
document.dispatchEvent(new Event('mouseup'))
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
art.destroy()
|
||||
frame.remove()
|
||||
return { sameError, received }
|
||||
})).toEqual({ sameError: true, received: ['old-document', 'old-window'] })
|
||||
})
|
||||
|
||||
for (const cause of ['source', 'rotation', 'geometry']) {
|
||||
test(`candidate closeout: ${cause} changes cancel the old gesture in the installed player`, async ({ page }) => {
|
||||
await setup(page, true)
|
||||
expect(await page.evaluate((cause) => {
|
||||
const art = window.art
|
||||
const video = art.template.$video
|
||||
let seeks = 0
|
||||
art.on('seek', () => seeks++)
|
||||
window.touch(video, 'touchstart', 100, 100)
|
||||
if (cause === 'source')
|
||||
art.url = '/test/pattern.mp4?closeout=source'
|
||||
if (cause === 'rotation')
|
||||
art.isRotate = true
|
||||
if (cause === 'geometry') {
|
||||
art.template.$player.style.setProperty('width', '0px', 'important')
|
||||
art.template.$player.style.setProperty('min-width', '0px', 'important')
|
||||
}
|
||||
const before = seeks
|
||||
const width = art.width
|
||||
window.touch(video, 'touchmove', cause === 'rotation' ? 100 : 140, cause === 'rotation' ? 140 : 100)
|
||||
art.destroy()
|
||||
return { newSeeks: seeks - before, zeroWidth: cause !== 'geometry' || width === 0 }
|
||||
}, cause)).toEqual({ newSeeks: 0, zeroWidth: true })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate closeout: adopted viewport retains exact gap boundaries', async ({ page }) => {
|
||||
await setup(page)
|
||||
expect(await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
const frame = document.createElement('iframe')
|
||||
frame.style.cssText = 'height:100px;width:200px'
|
||||
document.body.append(frame)
|
||||
const container = art.template.$container
|
||||
container.style.cssText = 'position:absolute;top:150px;left:0;height:20px;width:20px'
|
||||
frame.contentDocument.body.append(container)
|
||||
art.events.bindGlobalEvents()
|
||||
art.constructor.SCROLL_GAP = 50
|
||||
const states = []
|
||||
art.on('view', value => states.push(value))
|
||||
frame.contentWindow.dispatchEvent(new Event('scroll'))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
container.style.top = '151px'
|
||||
frame.contentWindow.dispatchEvent(new Event('scroll'))
|
||||
art.destroy()
|
||||
frame.remove()
|
||||
return states
|
||||
})).toEqual([true, false])
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: null options preserve the native default and callbacks can be removed directly`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const target = document.createElement('div')
|
||||
let hits = 0
|
||||
let failed = false
|
||||
const callback = () => hits++
|
||||
try {
|
||||
const remove = art.events.proxy(target, 'example', callback, null)
|
||||
target.dispatchEvent(new Event('example'))
|
||||
remove()
|
||||
target.dispatchEvent(new Event('example'))
|
||||
}
|
||||
catch {
|
||||
failed = true
|
||||
}
|
||||
art.destroy()
|
||||
return { hits, failed }
|
||||
})).toEqual({ hits: 1, failed: false })
|
||||
})
|
||||
|
||||
test(`${core}: once and abort use native event identity and permit subsequent registration`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const target = document.createElement('div')
|
||||
const seen = []
|
||||
const event = new Event('example')
|
||||
const callback = function (received) {
|
||||
seen.push(this === target && received === event)
|
||||
}
|
||||
const once = art.events.proxy(target, 'example', callback, { once: true })
|
||||
target.dispatchEvent(event)
|
||||
target.dispatchEvent(event)
|
||||
once()
|
||||
const controller = new AbortController()
|
||||
const aborted = art.events.proxy(target, 'example', callback, { signal: controller.signal })
|
||||
target.dispatchEvent(event)
|
||||
controller.abort()
|
||||
target.dispatchEvent(event)
|
||||
aborted()
|
||||
const skipped = art.events.proxy(target, 'example', callback, { signal: controller.signal })
|
||||
target.dispatchEvent(event)
|
||||
skipped()
|
||||
art.events.proxy(target, 'example', callback)
|
||||
target.dispatchEvent(event)
|
||||
art.destroy()
|
||||
target.dispatchEvent(event)
|
||||
return seen
|
||||
})).toEqual([true, true, true])
|
||||
})
|
||||
|
||||
test(`${core}: direct event disposer preserves native callback identity and releases bookkeeping`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const target = document.createElement('div')
|
||||
let hits = 0
|
||||
const callback = () => hits++
|
||||
const before = art.events.destroyEvents.size
|
||||
const remove = art.events.proxy(target, 'example', callback)
|
||||
target.removeEventListener('example', callback)
|
||||
target.dispatchEvent(new Event('example'))
|
||||
const returned = remove()
|
||||
const result = { hits, returned: typeof returned, remaining: art.events.destroyEvents.size - before }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ hits: 0, returned: 'undefined', remaining: core === 'candidate' ? 0 : 1 })
|
||||
})
|
||||
|
||||
test(`${core}: changing capture options after registration cannot prevent cleanup`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const target = document.createElement('div')
|
||||
let hits = 0
|
||||
const options = { capture: true }
|
||||
const remove = art.events.proxy(target, 'example', () => hits++, options)
|
||||
options.capture = false
|
||||
remove()
|
||||
target.dispatchEvent(new Event('example'))
|
||||
art.destroy()
|
||||
return hits
|
||||
})).toBe(core === 'candidate' ? 0 : 1)
|
||||
})
|
||||
|
||||
test(`${core}: partially failing array registration rolls back the listeners it created`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const target = document.createElement('div')
|
||||
const original = target.addEventListener
|
||||
const failure = new Error('second registration denied')
|
||||
target.addEventListener = function (name, ...args) {
|
||||
if (name === 'second')
|
||||
throw failure
|
||||
return original.call(this, name, ...args)
|
||||
}
|
||||
let hits = 0
|
||||
let sameError = false
|
||||
const before = art.events.destroyEvents.size
|
||||
try {
|
||||
art.events.proxy(target, ['first', 'second'], () => hits++)
|
||||
}
|
||||
catch (error) {
|
||||
sameError = error === failure
|
||||
}
|
||||
target.addEventListener = original
|
||||
target.dispatchEvent(new Event('first'))
|
||||
const result = { hits, sameError, remaining: art.events.destroyEvents.size - before }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ hits: core === 'candidate' ? 0 : 1, sameError: true, remaining: core === 'candidate' ? 0 : 1 })
|
||||
})
|
||||
|
||||
test(`${core}: registration throwing after native add does not leave an untracked listener`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const target = document.createElement('div')
|
||||
const original = target.addEventListener
|
||||
const failure = new Error('after native add')
|
||||
target.addEventListener = function (...args) {
|
||||
original.apply(this, args)
|
||||
throw failure
|
||||
}
|
||||
let hits = 0
|
||||
let sameError = false
|
||||
try {
|
||||
art.events.proxy(target, 'example', () => hits++)
|
||||
}
|
||||
catch (error) {
|
||||
sameError = error === failure
|
||||
}
|
||||
target.addEventListener = original
|
||||
target.dispatchEvent(new Event('example'))
|
||||
art.destroy()
|
||||
return { hits, sameError }
|
||||
})).toEqual({ hits: core === 'candidate' ? 0 : 1, sameError: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core, mobile = false) {
|
||||
if (mobile)
|
||||
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android pointer fixture' }))
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: real mouse input focuses the player, hovers and toggles actual playback`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
await page.evaluate(() => {
|
||||
window.art.constructor.DBCLICK_FULLSCREEN = false
|
||||
window.art.constructor.DBCLICK_TIME = 0
|
||||
window.pointerTrusted = []
|
||||
window.art.on('click', event => window.pointerTrusted.push(event.isTrusted))
|
||||
})
|
||||
const video = page.locator('.art-video')
|
||||
await video.click({ position: { x: 100, y: 100 } })
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
|
||||
expect(await page.evaluate(() => ({ focus: window.art.isFocus, hover: window.art.template.$player.classList.contains('art-hover') }))).toEqual({ focus: true, hover: true })
|
||||
await video.click({ position: { x: 100, y: 100 } })
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused)).toBe(true)
|
||||
expect(await page.evaluate(() => window.pointerTrusted)).toEqual([true, true])
|
||||
await page.mouse.move(1, 1)
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$player.classList.contains('art-hover'))).toBe(false)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: click pairs preserve callback identity and playback ordering`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.constructor.DBCLICK_TIME = 60000
|
||||
art.constructor.DBCLICK_FULLSCREEN = false
|
||||
const calls = []
|
||||
const events = [new MouseEvent('click'), new MouseEvent('click'), new MouseEvent('click')]
|
||||
let current
|
||||
art.template.$video.play = () => {
|
||||
calls.push('play')
|
||||
return Promise.resolve()
|
||||
}
|
||||
for (const name of ['click', 'dblclick']) {
|
||||
art.on(name, function (event) {
|
||||
calls.push(`${name}:${event === current}:${this === art}`)
|
||||
}, art)
|
||||
}
|
||||
for (const event of events) {
|
||||
current = event
|
||||
art.template.$video.dispatchEvent(event)
|
||||
}
|
||||
art.destroy()
|
||||
return calls
|
||||
})).toEqual(['click:true:true', 'play', 'dblclick:true:true', 'click:true:true', 'play'])
|
||||
})
|
||||
|
||||
for (const name of ['click', 'dblclick']) {
|
||||
test(`${core}: destruction in ${name} stops its subsequent default action`, async ({ page }) => {
|
||||
await setup(page, core, name === 'dblclick')
|
||||
expect(await page.evaluate((name) => {
|
||||
const art = window.art
|
||||
art.constructor.DBCLICK_TIME = 60000
|
||||
let plays = 0
|
||||
art.template.$video.play = () => {
|
||||
plays++
|
||||
return Promise.resolve()
|
||||
}
|
||||
art.on(name, () => art.destroy())
|
||||
const video = art.template.$video
|
||||
video.dispatchEvent(new MouseEvent('click'))
|
||||
if (name === 'dblclick')
|
||||
video.dispatchEvent(new MouseEvent('click'))
|
||||
return { plays, destroyed: art.isDestroy }
|
||||
}, name)).toEqual({ plays: core === 'published' ? 1 : 0, destroyed: true })
|
||||
})
|
||||
}
|
||||
|
||||
test(`${core}: focus, hover and move preserve flags, classes and original events`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const { $player } = art.template
|
||||
const input = document.createElement('input')
|
||||
$player.append(input)
|
||||
const calls = []
|
||||
let expected
|
||||
for (const name of ['focus', 'blur', 'mousemove'])
|
||||
art.on(name, event => calls.push([name, event === expected, art.isFocus, art.isInput]))
|
||||
art.on('hover', (state, event) => calls.push(['hover', state, event === expected, $player.classList.contains('art-hover')]))
|
||||
expected = new MouseEvent('click', { bubbles: true })
|
||||
input.dispatchEvent(expected)
|
||||
expected = new MouseEvent('contextmenu', { bubbles: true })
|
||||
document.body.dispatchEvent(expected)
|
||||
expected = new MouseEvent('mouseenter')
|
||||
$player.dispatchEvent(expected)
|
||||
expected = new MouseEvent('mousemove')
|
||||
$player.dispatchEvent(expected)
|
||||
expected = new MouseEvent('mouseleave')
|
||||
$player.dispatchEvent(expected)
|
||||
art.destroy()
|
||||
return calls
|
||||
})).toEqual([
|
||||
['focus', true, true, true],
|
||||
['blur', true, false, false],
|
||||
['hover', true, true, true],
|
||||
['mousemove', true, false, false],
|
||||
['hover', false, true, false],
|
||||
])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { resizeInit, viewInit, updateInit, inViewport, Emitter, beginLifecycle, getScope } = await loadModules({
|
||||
resizeInit: 'packages/artplayer/src/events/resizeInit',
|
||||
viewInit: 'packages/artplayer/src/events/viewInit',
|
||||
updateInit: 'packages/artplayer/src/events/updateInit',
|
||||
inViewport: { file: 'packages/artplayer/src/events/viewport', name: 'inViewport' },
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
})
|
||||
|
||||
function fixture(t) {
|
||||
const timers = new Map()
|
||||
const frames = new Map()
|
||||
let id = 0
|
||||
t.mock.method(globalThis, 'setTimeout', (callback, delay) => {
|
||||
const key = ++id
|
||||
timers.set(key, { callback, delay })
|
||||
return key
|
||||
})
|
||||
t.mock.method(globalThis, 'clearTimeout', key => timers.delete(key))
|
||||
for (const [name, value] of Object.entries({
|
||||
requestAnimationFrame(callback) {
|
||||
const key = ++id
|
||||
frames.set(key, callback)
|
||||
return key
|
||||
},
|
||||
cancelAnimationFrame: key => frames.delete(key),
|
||||
})) {
|
||||
const before = Object.getOwnPropertyDescriptor(globalThis, name)
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value })
|
||||
t.after(() => before ? Object.defineProperty(globalThis, name, before) : Reflect.deleteProperty(globalThis, name))
|
||||
}
|
||||
const orientation = new EventTarget()
|
||||
orientation.onchange = null
|
||||
const document = { defaultView: { innerHeight: 100, innerWidth: 200, screen: { orientation } }, documentElement: { clientHeight: 80, clientWidth: 160 } }
|
||||
const rect = { top: 0, left: 0, width: 20, height: 20 }
|
||||
const container = { ownerDocument: document, getBoundingClientRect: () => rect }
|
||||
const calls = []
|
||||
const art = Object.assign(new Emitter(), {
|
||||
template: { $player: { ownerDocument: document }, $container: container },
|
||||
constructor: { RESIZE_TIME: 10, SCROLL_TIME: 20, SCROLL_GAP: 0, USE_RAF: true },
|
||||
option: { autoSize: true, autoMini: true },
|
||||
aspectRatio: '16:9',
|
||||
state: 'standard',
|
||||
autoSize: () => calls.push('autoSize'),
|
||||
mini: false,
|
||||
playing: false,
|
||||
notice: { show: 'before' },
|
||||
})
|
||||
beginLifecycle(art)
|
||||
const scope = getScope(art)
|
||||
const registry = { proxy(target, name, callback) {
|
||||
target.addEventListener(name, callback)
|
||||
scope.add(() => {
|
||||
target.removeEventListener(name, callback)
|
||||
})
|
||||
} }
|
||||
const flushTimers = () => {
|
||||
const pending = [...timers.values()]
|
||||
timers.clear()
|
||||
for (const timer of pending)
|
||||
timer.callback()
|
||||
}
|
||||
const frame = () => {
|
||||
const [key, callback] = frames.entries().next().value
|
||||
frames.delete(key)
|
||||
callback(0)
|
||||
}
|
||||
return { art, scope, registry, calls, timers, frames, frame, flushTimers, orientation, rect, document }
|
||||
}
|
||||
|
||||
test('resize coalesces with live delay, preserves normal effects and cancels on destruction', (t) => {
|
||||
const f = fixture(t)
|
||||
resizeInit(f.art, f.registry)
|
||||
f.art.on('resize', () => f.calls.push('resize'))
|
||||
f.art.emit('window:resize', new Event('resize'))
|
||||
f.art.emit('window:orientationchange', new Event('orientationchange'))
|
||||
assert.equal(f.timers.size, 1)
|
||||
assert.equal([...f.timers.values()][0].delay, 10)
|
||||
f.art.constructor.RESIZE_TIME = 30
|
||||
f.art.emit('window:resize', new Event('resize'))
|
||||
assert.equal([...f.timers.values()][0].delay, 30)
|
||||
f.flushTimers()
|
||||
assert.deepEqual(f.calls, ['autoSize', 'resize'])
|
||||
assert.equal(f.art.aspectRatio, '16:9')
|
||||
assert.equal(f.art.notice.show, '')
|
||||
f.art.emit('window:resize', new Event('resize'))
|
||||
f.scope.dispose()
|
||||
assert.equal(f.timers.size, 0)
|
||||
f.art.emit('window:resize', new Event('resize'))
|
||||
assert.equal(f.timers.size, 0)
|
||||
})
|
||||
|
||||
test('orientation event capability does not depend on onchange being assigned', (t) => {
|
||||
const f = fixture(t)
|
||||
resizeInit(f.art, f.registry)
|
||||
f.orientation.dispatchEvent(new Event('change'))
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.flushTimers()
|
||||
assert.deepEqual(f.calls, ['autoSize'])
|
||||
f.scope.dispose()
|
||||
f.orientation.dispatchEvent(new Event('change'))
|
||||
assert.equal(f.timers.size, 0)
|
||||
})
|
||||
|
||||
test('destroy inside auto-size prevents later ratio and notice writes', (t) => {
|
||||
const f = fixture(t)
|
||||
let ratioWrites = 0
|
||||
Object.defineProperty(f.art, 'aspectRatio', { get: () => '16:9', set: () => ratioWrites++ })
|
||||
f.art.autoSize = () => f.scope.dispose()
|
||||
resizeInit(f.art, f.registry)
|
||||
f.art.emit('resize')
|
||||
assert.equal(ratioWrites, 0)
|
||||
assert.equal(f.art.notice.show, 'before')
|
||||
})
|
||||
|
||||
test('view preserves leading-only throttle, initial delay and live gap while cleaning its timer', (t) => {
|
||||
const f = fixture(t)
|
||||
const seen = []
|
||||
viewInit(f.art)
|
||||
f.art.on('view', value => seen.push(value))
|
||||
f.art.constructor.SCROLL_TIME = 99
|
||||
f.art.emit('window:scroll', new Event('scroll'))
|
||||
f.art.emit('window:scroll', new Event('scroll'))
|
||||
assert.deepEqual(seen, [true])
|
||||
assert.equal([...f.timers.values()][0].delay, 20)
|
||||
f.rect.top = 130
|
||||
f.flushTimers()
|
||||
assert.deepEqual(seen, [true])
|
||||
f.art.emit('window:scroll', new Event('scroll'))
|
||||
assert.deepEqual(seen, [true, false])
|
||||
assert.equal(f.art.mini, true)
|
||||
f.flushTimers()
|
||||
f.art.constructor.SCROLL_GAP = 50
|
||||
f.art.emit('window:scroll', new Event('scroll'))
|
||||
assert.deepEqual(seen, [true, false, true])
|
||||
f.scope.dispose()
|
||||
assert.equal(f.timers.size, 0)
|
||||
f.art.emit('window:scroll', new Event('scroll'))
|
||||
assert.deepEqual(seen, [true, false, true])
|
||||
})
|
||||
|
||||
test('view callback failure remains synchronous and does not lock the throttle', (t) => {
|
||||
const f = fixture(t)
|
||||
viewInit(f.art)
|
||||
const error = new Error('view failed')
|
||||
f.art.once('view', () => {
|
||||
throw error
|
||||
})
|
||||
assert.throws(() => f.art.emit('window:scroll', new Event('scroll')), actual => actual === error)
|
||||
assert.equal(f.timers.size, 0)
|
||||
f.art.emit('window:scroll', new Event('scroll'))
|
||||
assert.equal(f.timers.size, 1)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('nested leading view dispatch keeps its timing and destroy prevents reset timers', (t) => {
|
||||
const f = fixture(t)
|
||||
viewInit(f.art)
|
||||
let views = 0
|
||||
f.art.on('view', () => views++)
|
||||
f.art.once('view', () => f.art.emit('window:scroll', new Event('scroll')))
|
||||
f.art.emit('window:scroll', new Event('scroll'))
|
||||
assert.equal(views, 2)
|
||||
assert.equal(f.timers.size, 2)
|
||||
f.flushTimers()
|
||||
f.art.once('view', () => f.scope.dispose())
|
||||
f.art.emit('window:scroll', new Event('scroll'))
|
||||
assert.equal(f.timers.size, 0)
|
||||
})
|
||||
|
||||
test('RAF keeps the initial playing emission and one frame while cancelling reentrant destruction', (t) => {
|
||||
const f = fixture(t)
|
||||
let frames = 0
|
||||
f.art.playing = true
|
||||
f.art.on('raf', () => frames++)
|
||||
updateInit(f.art)
|
||||
assert.equal(frames, 1)
|
||||
assert.equal(f.frames.size, 1)
|
||||
f.art.playing = false
|
||||
f.frame()
|
||||
assert.equal(frames, 1)
|
||||
assert.equal(f.frames.size, 1)
|
||||
f.art.playing = true
|
||||
f.art.once('raf', () => f.scope.dispose())
|
||||
f.frame()
|
||||
assert.equal(frames, 2)
|
||||
assert.equal(f.frames.size, 0)
|
||||
})
|
||||
|
||||
test('RAF remains opt-in and explicit destroy notification can cancel an idle frame', (t) => {
|
||||
const f = fixture(t)
|
||||
f.art.constructor.USE_RAF = false
|
||||
updateInit(f.art)
|
||||
assert.equal(f.frames.size, 0)
|
||||
f.art.constructor.USE_RAF = true
|
||||
updateInit(f.art)
|
||||
assert.equal(f.frames.size, 1)
|
||||
f.art.emit('destroy')
|
||||
assert.equal(f.frames.size, 0)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('viewport follows owner document changes and preserves edge/gap arithmetic', (t) => {
|
||||
const f = fixture(t)
|
||||
const container = f.art.template.$container
|
||||
f.rect.top = 150
|
||||
assert.equal(inViewport(container, 50), true)
|
||||
f.rect.top = 151
|
||||
assert.equal(inViewport(container, 50), false)
|
||||
container.ownerDocument = { defaultView: null, documentElement: { clientHeight: 200, clientWidth: 200 } }
|
||||
assert.equal(inViewport(container, 0), true)
|
||||
f.rect.left = 301
|
||||
assert.equal(inViewport(container, 50), false)
|
||||
f.rect.left = 300
|
||||
assert.equal(inViewport(container, 50), true)
|
||||
f.scope.dispose()
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const savedAgent = Object.getOwnPropertyDescriptor(globalThis, 'CUSTOM_USER_AGENT')
|
||||
Object.defineProperty(globalThis, 'CUSTOM_USER_AGENT', { configurable: true, value: 'ArtPlayer Android gesture unit fixture' })
|
||||
let modules
|
||||
try {
|
||||
modules = await loadModules({
|
||||
gestureController: { file: 'packages/artplayer/src/input/gesture-controller', name: 'gestureController' },
|
||||
slideDirection: { file: 'packages/artplayer/src/input/gesture-direction', name: 'slideDirection' },
|
||||
gestureInit: 'packages/artplayer/src/events/gestureInit',
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
beginSource: { file: 'packages/artplayer/src/source/operation', name: 'beginSource' },
|
||||
})
|
||||
}
|
||||
finally {
|
||||
if (savedAgent)
|
||||
Object.defineProperty(globalThis, 'CUSTOM_USER_AGENT', savedAgent)
|
||||
else
|
||||
delete globalThis.CUSTOM_USER_AGENT
|
||||
}
|
||||
const { gestureController, slideDirection, gestureInit, Emitter, beginLifecycle, getScope, beginSource } = modules
|
||||
|
||||
function touch(x, y, identifier = 1, count = 1) {
|
||||
return { touches: Array.from({ length: count }, () => ({ pageX: x, pageY: y, clientX: x, clientY: y, identifier })) }
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const calls = []
|
||||
const art = Object.assign(new Emitter(), {
|
||||
template: { $video: {}, $progress: { clientWidth: 100, getBoundingClientRect: () => ({ left: 10 }) } },
|
||||
option: { isLive: false, gesture: true },
|
||||
constructor: { TOUCH_MOVE_RATIO: 0.5 },
|
||||
currentTime: 20,
|
||||
duration: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
top: 10,
|
||||
isRotate: false,
|
||||
isLock: false,
|
||||
notice: { show: '' },
|
||||
})
|
||||
// eslint-disable-next-line accessor-pairs -- The real seek API is write-only.
|
||||
Object.defineProperty(art, 'seek', {
|
||||
set(time) {
|
||||
art.currentTime = time
|
||||
calls.push(['seek', time])
|
||||
art.emit('seek', time)
|
||||
},
|
||||
})
|
||||
art.on('setBar', (kind, value, event) => calls.push(['bar', kind, value, event]))
|
||||
beginLifecycle(art)
|
||||
const gesture = gestureController(art)
|
||||
return { art, gesture, calls, dispose: () => getScope(art).dispose() }
|
||||
}
|
||||
|
||||
test('gesture directions retain the two-pixel threshold and exact diagonal boundaries', () => {
|
||||
for (const [x, y, expected] of [[1, 1, 0], [2, 0, 4], [0, 2, 2], [-2, 0, 3], [0, -2, 1], [2, 2, 4], [2, -2, 1], [-2, -2, 3], [-2, 2, 2]])
|
||||
assert.equal(slideDirection(0, 0, x, y), expected)
|
||||
assert.equal(slideDirection(0, 0, Number.NaN, 0), 0)
|
||||
})
|
||||
|
||||
test('video dragging retains seek/bar/notice values and clamps against duration', () => {
|
||||
const f = fixture()
|
||||
const event = touch(40, 0)
|
||||
f.gesture.start(f.art.template.$video, touch(0, 0))
|
||||
f.gesture.move(event)
|
||||
assert.deepEqual(f.calls, [['seek', 30], ['bar', 'played', 0.3, event]])
|
||||
assert.equal(f.art.notice.show, '00:30 / 01:40')
|
||||
f.art.constructor.TOUCH_MOVE_RATIO = 2
|
||||
f.gesture.move(touch(1000, 0))
|
||||
assert.equal(f.art.currentTime, 100)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('progress start retains bar-before-seek and its full-width drag multiplier', () => {
|
||||
const f = fixture()
|
||||
const event = touch(60, 20)
|
||||
f.gesture.start(f.art.template.$progress, event)
|
||||
assert.deepEqual(f.calls, [['bar', 'played', 0.5, event], ['seek', 50]])
|
||||
f.gesture.move(touch(100, 20))
|
||||
assert.equal(f.art.currentTime, 70)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('rotation uses the vertical axis for absolute progress and subsequent drag', () => {
|
||||
const f = fixture()
|
||||
f.art.isRotate = true
|
||||
f.gesture.start(f.art.template.$progress, touch(60, 60))
|
||||
assert.equal(f.art.currentTime, 50)
|
||||
f.gesture.move(touch(90, 60))
|
||||
assert.equal(f.art.currentTime, 50)
|
||||
f.gesture.move(touch(60, 80))
|
||||
assert.equal(f.art.currentTime, 70)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('cancel, lock, multitouch, changed finger, source and rotation cannot resume the old drag', () => {
|
||||
for (const reason of ['cancel', 'lock', 'multitouch', 'identifier', 'source', 'rotation']) {
|
||||
const f = fixture()
|
||||
f.gesture.start(f.art.template.$video, touch(0, 0))
|
||||
if (reason === 'cancel')
|
||||
f.art.emit('document:touchcancel', {})
|
||||
if (reason === 'lock') {
|
||||
f.art.isLock = true
|
||||
f.art.emit('lock', true)
|
||||
f.art.isLock = false
|
||||
}
|
||||
if (reason === 'multitouch')
|
||||
f.gesture.move(touch(10, 0, 1, 2))
|
||||
if (reason === 'identifier')
|
||||
f.gesture.move(touch(10, 0, 2))
|
||||
if (reason === 'source')
|
||||
beginSource(f.art)
|
||||
if (reason === 'rotation')
|
||||
f.art.isRotate = true
|
||||
f.gesture.move(touch(40, 0))
|
||||
assert.equal(f.art.currentTime, 20, reason)
|
||||
f.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('non-finite or zero geometry cancels movement instead of producing invalid positions', () => {
|
||||
for (const [key, value] of [['width', 0], ['width', Infinity], ['duration', Infinity], ['duration', 0]]) {
|
||||
const f = fixture()
|
||||
f.gesture.start(f.art.template.$video, touch(0, 0))
|
||||
const before = f.art[key]
|
||||
f.art[key] = value
|
||||
f.gesture.move(touch(40, 0))
|
||||
f.art[key] = before
|
||||
f.gesture.move(touch(60, 0))
|
||||
assert.deepEqual(f.calls, [], key)
|
||||
f.dispose()
|
||||
}
|
||||
const f = fixture()
|
||||
f.art.template.$progress.clientWidth = 0
|
||||
f.gesture.start(f.art.template.$progress, touch(60, 0))
|
||||
assert.deepEqual(f.calls, [])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('destroy inside absolute progress notification prevents the following seek', () => {
|
||||
const f = fixture()
|
||||
f.art.on('setBar', f.dispose)
|
||||
f.gesture.start(f.art.template.$progress, touch(60, 0))
|
||||
assert.equal(f.art.currentTime, 20)
|
||||
assert.equal(f.calls.length, 1)
|
||||
f.gesture.move(touch(80, 0))
|
||||
assert.equal(f.calls.length, 1)
|
||||
})
|
||||
|
||||
test('destroy inside relative seek prevents bar and notice updates', () => {
|
||||
const f = fixture()
|
||||
f.art.on('seek', f.dispose)
|
||||
f.gesture.start(f.art.template.$video, touch(0, 0))
|
||||
f.gesture.move(touch(40, 0))
|
||||
assert.deepEqual(f.calls, [['seek', 30]])
|
||||
assert.equal(f.art.notice.show, '')
|
||||
})
|
||||
|
||||
test('nested touch start supersedes the interrupted absolute progress transaction', () => {
|
||||
const f = fixture()
|
||||
f.art.once('setBar', () => f.gesture.start(f.art.template.$video, touch(0, 0, 2)))
|
||||
f.gesture.start(f.art.template.$progress, touch(60, 0))
|
||||
assert.equal(f.art.currentTime, 20)
|
||||
f.gesture.move(touch(40, 0, 2))
|
||||
assert.equal(f.art.currentTime, 30)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('failed progress start retains the caller error and cancels the unfinished drag', () => {
|
||||
const f = fixture()
|
||||
const error = new Error('progress callback failed')
|
||||
f.art.once('setBar', () => {
|
||||
throw error
|
||||
})
|
||||
assert.throws(() => f.gesture.start(f.art.template.$progress, touch(60, 0)), thrown => thrown === error)
|
||||
f.gesture.move(touch(80, 0))
|
||||
assert.equal(f.art.currentTime, 20)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('touch end and owner disposal remove internal subscriptions but preserve external listeners', () => {
|
||||
const f = fixture()
|
||||
let external = 0
|
||||
f.art.on('document:touchend', () => external++)
|
||||
f.gesture.start(f.art.template.$video, touch(0, 0))
|
||||
f.art.emit('document:touchend', {})
|
||||
f.gesture.move(touch(40, 0))
|
||||
assert.equal(f.art.currentTime, 20)
|
||||
f.dispose()
|
||||
f.art.emit('document:touchend', {})
|
||||
assert.equal(external, 2)
|
||||
assert.equal(f.art.e['document:touchcancel'], undefined)
|
||||
assert.equal(f.art.e.lock, undefined)
|
||||
})
|
||||
|
||||
test('mobile gesture=false still installs progress interactions, while live mode installs none', () => {
|
||||
const f = fixture()
|
||||
f.art.option.gesture = false
|
||||
const registrations = []
|
||||
const events = { proxy: (target, name) => registrations.push([target, name]) }
|
||||
gestureInit(f.art, events)
|
||||
assert.deepEqual(registrations.map(item => item[1]), ['touchstart', 'touchmove', 'touchcancel'])
|
||||
assert(registrations.every(item => item[0] === f.art.template.$progress))
|
||||
f.art.option.isLive = true
|
||||
registrations.length = 0
|
||||
gestureInit(f.art, events)
|
||||
assert.deepEqual(registrations, [])
|
||||
f.dispose()
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { globalInit, ownListeners, proxyListener, removeListener, destroyListeners, beginLifecycle, getScope } = await loadModules({
|
||||
globalInit: 'packages/artplayer/src/events/globalInit',
|
||||
...Object.fromEntries(['ownListeners', 'proxyListener', 'removeListener', 'destroyListeners'].map(name => [name, { file: 'packages/artplayer/src/events/listener-registry', name }])),
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
})
|
||||
|
||||
function source() {
|
||||
const window = new EventTarget()
|
||||
const document = Object.assign(new EventTarget(), { defaultView: window })
|
||||
return { document, window }
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const initial = source()
|
||||
const received = []
|
||||
const art = {
|
||||
template: { $player: { ownerDocument: initial.document } },
|
||||
emit: (...args) => received.push(args),
|
||||
}
|
||||
beginLifecycle(art)
|
||||
const registry = { destroyEvents: new Set() }
|
||||
ownListeners(registry, art)
|
||||
registry.proxy = (...args) => proxyListener(registry, ...args)
|
||||
registry.remove = dispose => removeListener(registry, dispose)
|
||||
const scope = getScope(art)
|
||||
scope.add(() => {
|
||||
destroyListeners(registry)
|
||||
return undefined
|
||||
})
|
||||
globalInit(art, registry)
|
||||
return { initial, art, registry, scope, received }
|
||||
}
|
||||
|
||||
test('global rebinding selects current owner targets, keeps event identity and original window fallback', () => {
|
||||
const f = fixture()
|
||||
const next = source()
|
||||
const event = new Event('mouseup')
|
||||
const scroll = new Event('scroll')
|
||||
f.registry.bindGlobalEvents({ document: next.document })
|
||||
f.initial.document.dispatchEvent(new Event('mouseup'))
|
||||
next.document.dispatchEvent(event)
|
||||
next.window.dispatchEvent(new Event('scroll'))
|
||||
f.initial.window.dispatchEvent(scroll)
|
||||
assert.deepEqual(f.received, [['document:mouseup', event], ['window:scroll', scroll]])
|
||||
f.art.template.$player.ownerDocument = next.document
|
||||
f.registry.bindGlobalEvents()
|
||||
f.initial.window.dispatchEvent(new Event('scroll'))
|
||||
next.window.dispatchEvent(scroll)
|
||||
assert.equal(f.received.length, 3)
|
||||
f.scope.dispose()
|
||||
next.document.dispatchEvent(event)
|
||||
assert.equal(f.received.length, 3)
|
||||
assert.equal(f.registry.destroyEvents.size, 0)
|
||||
})
|
||||
|
||||
test('window registration failure rolls back the staged document while old listeners keep working', () => {
|
||||
const f = fixture()
|
||||
const next = source()
|
||||
const failure = new Error('window failure')
|
||||
next.window.addEventListener = () => {
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => f.registry.bindGlobalEvents(next), error => error === failure)
|
||||
const event = new Event('mouseup')
|
||||
next.document.dispatchEvent(new Event('mouseup'))
|
||||
f.initial.document.dispatchEvent(event)
|
||||
assert.deepEqual(f.received, [['document:mouseup', event]])
|
||||
assert.equal(f.registry.destroyEvents.size, 15)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('staged listeners do not forward before commit while the previous binding remains active', () => {
|
||||
const f = fixture()
|
||||
const next = source()
|
||||
const original = next.window.addEventListener
|
||||
const oldEvent = new Event('mouseup')
|
||||
next.window.addEventListener = function (name, ...args) {
|
||||
if (name === 'resize') {
|
||||
next.document.dispatchEvent(new Event('mouseup'))
|
||||
f.initial.document.dispatchEvent(oldEvent)
|
||||
}
|
||||
return original.call(this, name, ...args)
|
||||
}
|
||||
f.registry.bindGlobalEvents(next)
|
||||
assert.deepEqual(f.received, [['document:mouseup', oldEvent]])
|
||||
const nextEvent = new Event('mouseup')
|
||||
next.document.dispatchEvent(nextEvent)
|
||||
f.initial.document.dispatchEvent(new Event('mouseup'))
|
||||
assert.deepEqual(f.received, [['document:mouseup', oldEvent], ['document:mouseup', nextEvent]])
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('a newer reentrant global binding wins and the interrupted registration is released', () => {
|
||||
const f = fixture()
|
||||
const interrupted = source()
|
||||
const newest = source()
|
||||
const original = interrupted.document.addEventListener
|
||||
interrupted.document.addEventListener = function (name, ...args) {
|
||||
if (name === 'click')
|
||||
f.registry.bindGlobalEvents(newest)
|
||||
return original.call(this, name, ...args)
|
||||
}
|
||||
f.registry.bindGlobalEvents(interrupted)
|
||||
f.initial.document.dispatchEvent(new Event('mouseup'))
|
||||
interrupted.document.dispatchEvent(new Event('mouseup'))
|
||||
const event = new Event('mouseup')
|
||||
newest.document.dispatchEvent(event)
|
||||
assert.deepEqual(f.received, [['document:mouseup', event]])
|
||||
assert.equal(f.registry.destroyEvents.size, 15)
|
||||
f.scope.dispose()
|
||||
})
|
||||
|
||||
test('destroy during rebinding clears staged and old listeners and later bind remains inert', () => {
|
||||
const f = fixture()
|
||||
const next = source()
|
||||
const original = next.document.addEventListener
|
||||
next.document.addEventListener = function (...args) {
|
||||
f.scope.dispose()
|
||||
return original.apply(this, args)
|
||||
}
|
||||
f.registry.bindGlobalEvents(next)
|
||||
assert.equal(f.registry.destroyEvents.size, 0)
|
||||
f.registry.bindGlobalEvents()
|
||||
f.initial.document.dispatchEvent(new Event('mouseup'))
|
||||
next.document.dispatchEvent(new Event('mouseup'))
|
||||
assert.deepEqual(f.received, [])
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { Hotkey, Emitter, beginLifecycle, getScope } = await loadModules({
|
||||
Hotkey: 'packages/artplayer/src/hotkey',
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
})
|
||||
|
||||
function fixture(enabled = true) {
|
||||
const document = { activeElement: { nodeType: 1, tagName: 'BODY', isContentEditable: false } }
|
||||
const art = Object.assign(new Emitter(), {
|
||||
template: { $player: { ownerDocument: document } },
|
||||
constructor: { SEEK_STEP: 5, VOLUME_STEP: 0.1 },
|
||||
option: { hotkey: enabled },
|
||||
isFocus: true,
|
||||
fullscreenWeb: true,
|
||||
volume: 0.5,
|
||||
toggle: () => undefined,
|
||||
})
|
||||
beginLifecycle(art)
|
||||
const hotkey = new Hotkey(art)
|
||||
function dispatch(code = 'KeyK', options = {}) {
|
||||
const event = Object.assign(new Event('keydown', { cancelable: true }), { code, ...options })
|
||||
art.emit('document:keydown', event)
|
||||
return event
|
||||
}
|
||||
return { art, hotkey, document, dispatch, dispose: () => getScope(art).dispose() }
|
||||
}
|
||||
|
||||
test('hotkeys preserve own art/keys fields, public prototype and registration identity', () => {
|
||||
const f = fixture()
|
||||
assert.deepEqual(Object.keys(f.hotkey), ['art', 'keys'])
|
||||
assert.deepEqual(Object.getOwnPropertyNames(Hotkey.prototype), ['constructor', 'init', 'add', 'remove'])
|
||||
assert.equal(Object.getPrototypeOf(f.hotkey.keys), Object.prototype)
|
||||
for (const key of ['__proto__', 'constructor', 'toString']) {
|
||||
const callback = () => {}
|
||||
assert.equal(f.hotkey.add(key, callback), f.hotkey)
|
||||
const array = f.hotkey.keys[key]
|
||||
f.hotkey.add(key, callback)
|
||||
assert.equal(array.length, 1)
|
||||
assert.equal(f.hotkey.remove(key, callback), f.hotkey)
|
||||
assert.deepEqual(array, [])
|
||||
assert.equal(Object.hasOwn(f.hotkey.keys, key), false)
|
||||
}
|
||||
assert.equal(Object.getPrototypeOf(f.hotkey.keys), Object.prototype)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('default hotkeys preserve live constants, operations and inactive focus behavior', () => {
|
||||
const f = fixture()
|
||||
f.dispatch('Escape')
|
||||
assert.equal(f.art.fullscreenWeb, false)
|
||||
f.art.constructor.SEEK_STEP = 9
|
||||
f.dispatch('ArrowLeft')
|
||||
f.dispatch('ArrowRight')
|
||||
assert.equal(f.art.backward, 9)
|
||||
assert.equal(f.art.forward, 9)
|
||||
f.dispatch('ArrowUp')
|
||||
assert.equal(f.art.volume, 0.6)
|
||||
f.art.isFocus = false
|
||||
assert.equal(f.dispatch('ArrowDown').defaultPrevented, false)
|
||||
assert.equal(f.art.volume, 0.6)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('default disabling still permits custom keys and later initialization installs defaults once', () => {
|
||||
const f = fixture(false)
|
||||
assert.deepEqual(Object.keys(f.hotkey.keys), [])
|
||||
let hits = 0
|
||||
f.hotkey.add('KeyK', () => hits++)
|
||||
f.dispatch()
|
||||
f.art.option.hotkey = true
|
||||
f.hotkey.init()
|
||||
f.hotkey.init()
|
||||
f.dispatch()
|
||||
assert.equal(hits, 2)
|
||||
assert.equal(f.hotkey.keys.Space.length, 1)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('explicit reinitialization restores removed defaults without adding duplicate callbacks', () => {
|
||||
const f = fixture()
|
||||
const callback = f.hotkey.keys.ArrowRight[0]
|
||||
f.hotkey.remove('ArrowRight', callback)
|
||||
assert.equal(f.hotkey.keys.ArrowRight, undefined)
|
||||
f.hotkey.init()
|
||||
f.hotkey.init()
|
||||
assert.deepEqual(f.hotkey.keys.ArrowRight, [callback])
|
||||
assert.equal(f.hotkey.keys.Space.length, 1)
|
||||
f.dispatch('ArrowRight')
|
||||
assert.equal(f.art.forward, 5)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('key dispatch retains callback receiver, event order and live-array removal behavior', () => {
|
||||
const f = fixture()
|
||||
const calls = []
|
||||
function first(event) {
|
||||
calls.push(['first', this === f.art, event.defaultPrevented])
|
||||
f.hotkey.remove('KeyK', first)
|
||||
}
|
||||
f.hotkey.add('KeyK', first)
|
||||
f.hotkey.add('KeyK', () => calls.push('second'))
|
||||
f.art.on('hotkey', () => calls.push('hotkey'))
|
||||
f.art.on('keydown', () => calls.push('keydown'))
|
||||
f.dispatch()
|
||||
f.dispatch()
|
||||
assert.deepEqual(calls, [['first', true, true], 'hotkey', 'keydown', 'second', 'hotkey', 'keydown'])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('destroy during a hotkey stops later callbacks and public notifications', () => {
|
||||
const f = fixture()
|
||||
const calls = []
|
||||
f.hotkey.add('KeyK', () => {
|
||||
calls.push('first')
|
||||
f.dispose()
|
||||
})
|
||||
f.hotkey.add('KeyK', () => calls.push('second'))
|
||||
f.art.on('hotkey', () => calls.push('hotkey'))
|
||||
f.art.on('keydown', () => calls.push('keydown'))
|
||||
f.dispatch()
|
||||
f.hotkey.init()
|
||||
f.dispatch()
|
||||
assert.deepEqual(calls, ['first'])
|
||||
assert.equal(f.art.e['document:keydown'], undefined)
|
||||
})
|
||||
|
||||
test('synchronous hotkey errors retain identity and do not emit success notifications', () => {
|
||||
const f = fixture()
|
||||
const error = new Error('callback failed')
|
||||
f.hotkey.add('KeyK', () => {
|
||||
throw error
|
||||
})
|
||||
let downstream = 0
|
||||
f.art.on('hotkey', () => downstream++)
|
||||
f.art.on('keydown', () => downstream++)
|
||||
assert.throws(() => f.dispatch(), value => value === error)
|
||||
assert.equal(downstream, 0)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('modifiers and composition suppress hotkeys but preserve generic keydown delivery', () => {
|
||||
const f = fixture()
|
||||
let keys = 0
|
||||
let generic = 0
|
||||
f.hotkey.add('KeyK', () => keys++)
|
||||
f.art.on('keydown', () => generic++)
|
||||
for (const options of [{ altKey: true }, { ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { isComposing: true }, { keyCode: 229 }])
|
||||
assert.equal(f.dispatch('KeyK', options).defaultPrevented, false)
|
||||
assert.equal(keys, 0)
|
||||
assert.equal(generic, 6)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('an absent active element still permits a focused player key without throwing', () => {
|
||||
const f = fixture()
|
||||
f.document.activeElement = null
|
||||
let hits = 0
|
||||
f.hotkey.add('KeyK', () => hits++)
|
||||
assert.equal(f.dispatch().defaultPrevented, true)
|
||||
assert.equal(hits, 1)
|
||||
f.dispose()
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { ownListeners, proxyListener, removeListener, destroyListeners, beginLifecycle, getScope } = await loadModules({
|
||||
...Object.fromEntries(['ownListeners', 'proxyListener', 'removeListener', 'destroyListeners'].map(name => [name, { file: 'packages/artplayer/src/events/listener-registry', name }])),
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const art = {}
|
||||
beginLifecycle(art)
|
||||
const registry = { destroyEvents: new Set() }
|
||||
ownListeners(registry, art)
|
||||
const scope = getScope(art)
|
||||
scope.add(() => {
|
||||
destroyListeners(registry)
|
||||
return undefined
|
||||
})
|
||||
return { registry, scope, target: new EventTarget() }
|
||||
}
|
||||
|
||||
test('native callbacks keep identity, receiver and event; direct disposal is idempotent', () => {
|
||||
const { registry, target, scope } = fixture()
|
||||
const received = []
|
||||
function callback(event) {
|
||||
received.push([this, event])
|
||||
}
|
||||
const dispose = proxyListener(registry, target, 'example', callback)
|
||||
const event = new Event('example')
|
||||
target.dispatchEvent(event)
|
||||
assert.deepEqual(received, [[target, event]])
|
||||
target.removeEventListener('example', callback)
|
||||
target.dispatchEvent(event)
|
||||
assert.equal(received.length, 1)
|
||||
assert.equal(dispose(), undefined)
|
||||
assert.equal(dispose(), undefined)
|
||||
assert.equal(registry.destroyEvents.size, 0)
|
||||
scope.dispose()
|
||||
})
|
||||
|
||||
test('capture is read once and removal uses the registration snapshot', () => {
|
||||
const { registry, scope } = fixture()
|
||||
const calls = []
|
||||
const target = {
|
||||
addEventListener: (...args) => calls.push(['add', ...args]),
|
||||
removeEventListener: (...args) => calls.push(['remove', ...args]),
|
||||
}
|
||||
let reads = 0
|
||||
const callback = () => {}
|
||||
const options = {
|
||||
get capture() { return ++reads === 1 },
|
||||
once: true,
|
||||
passive: false,
|
||||
}
|
||||
proxyListener(registry, target, 'example', callback, options)()
|
||||
assert.equal(reads, 1)
|
||||
assert.deepEqual(calls, [
|
||||
['add', 'example', callback, { capture: true, once: true, passive: false, signal: undefined }],
|
||||
['remove', 'example', callback, true],
|
||||
])
|
||||
scope.dispose()
|
||||
})
|
||||
|
||||
test('once and object listeners use native dispatch; explicit disposal clears their records', () => {
|
||||
const { registry, target, scope } = fixture()
|
||||
const listener = {
|
||||
hits: 0,
|
||||
handleEvent() { this.hits++ },
|
||||
}
|
||||
const dispose = proxyListener(registry, target, 'example', listener, { once: true })
|
||||
target.dispatchEvent(new Event('example'))
|
||||
target.dispatchEvent(new Event('example'))
|
||||
assert.equal(listener.hits, 1)
|
||||
assert.equal(registry.destroyEvents.has(dispose), true)
|
||||
dispose()
|
||||
assert.equal(registry.destroyEvents.size, 0)
|
||||
scope.dispose()
|
||||
})
|
||||
|
||||
test('aborted signals skip registration and later abort clears the owned record', () => {
|
||||
const { registry, target, scope } = fixture()
|
||||
let hits = 0
|
||||
const controller = new AbortController()
|
||||
proxyListener(registry, target, 'example', () => hits++, { signal: controller.signal })
|
||||
target.dispatchEvent(new Event('example'))
|
||||
controller.abort()
|
||||
target.dispatchEvent(new Event('example'))
|
||||
assert.equal(hits, 1)
|
||||
assert.equal(registry.destroyEvents.size, 0)
|
||||
proxyListener(registry, target, 'example', () => hits++, { signal: controller.signal })
|
||||
target.dispatchEvent(new Event('example'))
|
||||
assert.equal(hits, 1)
|
||||
assert.equal(registry.destroyEvents.size, 0)
|
||||
scope.dispose()
|
||||
})
|
||||
|
||||
test('registration failure after native add rolls back an entire array and keeps error identity', () => {
|
||||
const { registry, target, scope } = fixture()
|
||||
const original = target.addEventListener
|
||||
const failure = new Error('registration failed')
|
||||
target.addEventListener = function (name, ...args) {
|
||||
original.call(this, name, ...args)
|
||||
if (name === 'second')
|
||||
throw failure
|
||||
}
|
||||
let hits = 0
|
||||
assert.throws(() => proxyListener(registry, target, ['first', 'second'], () => hits++), error => error === failure)
|
||||
target.dispatchEvent(new Event('first'))
|
||||
target.dispatchEvent(new Event('second'))
|
||||
assert.equal(hits, 0)
|
||||
assert.equal(registry.destroyEvents.size, 0)
|
||||
scope.dispose()
|
||||
})
|
||||
|
||||
test('destruction during native registration removes a listener added after cleanup', () => {
|
||||
const { registry, target, scope } = fixture()
|
||||
const original = target.addEventListener
|
||||
target.addEventListener = function (...args) {
|
||||
scope.dispose()
|
||||
original.apply(this, args)
|
||||
}
|
||||
let hits = 0
|
||||
proxyListener(registry, target, 'example', () => hits++)
|
||||
target.dispatchEvent(new Event('example'))
|
||||
assert.equal(hits, 0)
|
||||
assert.equal(registry.destroyEvents.size, 0)
|
||||
assert.equal(scope.closed, true)
|
||||
})
|
||||
|
||||
test('failed removal retains a retryable record and does not prevent other cleanup', (t) => {
|
||||
const { registry, target, scope } = fixture()
|
||||
const failure = new Error('temporary removal failure')
|
||||
const warnings = []
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args))
|
||||
const original = target.removeEventListener
|
||||
let denied = true
|
||||
target.removeEventListener = function (name, ...args) {
|
||||
if (denied && name === 'first')
|
||||
throw failure
|
||||
original.call(this, name, ...args)
|
||||
}
|
||||
const calls = []
|
||||
const first = proxyListener(registry, target, 'first', () => calls.push('first'))
|
||||
proxyListener(registry, target, 'second', () => calls.push('second'))
|
||||
removeListener(registry, first)
|
||||
assert.equal(registry.destroyEvents.size, 2)
|
||||
destroyListeners(registry)
|
||||
assert.equal(registry.destroyEvents.size, 1)
|
||||
target.dispatchEvent(new Event('first'))
|
||||
target.dispatchEvent(new Event('second'))
|
||||
assert.deepEqual(calls, ['first'])
|
||||
assert.deepEqual(warnings.map(item => item[1]), [failure, failure])
|
||||
denied = false
|
||||
destroyListeners(registry)
|
||||
assert.equal(registry.destroyEvents.size, 0)
|
||||
target.dispatchEvent(new Event('first'))
|
||||
assert.deepEqual(calls, ['first'])
|
||||
scope.dispose()
|
||||
})
|
||||
|
||||
test('manual destroy suppresses reentrant registrations but allows reuse until owner closes', () => {
|
||||
const { registry, target, scope } = fixture()
|
||||
const original = target.removeEventListener
|
||||
let hits = 0
|
||||
target.removeEventListener = function (...args) {
|
||||
destroyListeners(registry)
|
||||
proxyListener(registry, target, 'reentrant', () => hits++)
|
||||
original.apply(this, args)
|
||||
}
|
||||
proxyListener(registry, target, 'first', () => hits++)
|
||||
destroyListeners(registry)
|
||||
target.dispatchEvent(new Event('reentrant'))
|
||||
assert.equal(hits, 0)
|
||||
assert.equal(registry.destroyEvents.size, 0)
|
||||
proxyListener(registry, target, 'next', () => hits++)
|
||||
target.dispatchEvent(new Event('next'))
|
||||
assert.equal(hits, 1)
|
||||
scope.dispose()
|
||||
proxyListener(registry, target, 'closed', () => hits++)
|
||||
target.dispatchEvent(new Event('closed'))
|
||||
assert.equal(hits, 1)
|
||||
assert.equal(registry.destroyEvents.size, 0)
|
||||
})
|
||||
|
||||
test('duplicate registrations preserve native callback deduplication without reference counting', () => {
|
||||
const { registry, target, scope } = fixture()
|
||||
let hits = 0
|
||||
const callback = () => hits++
|
||||
const first = proxyListener(registry, target, 'example', callback)
|
||||
proxyListener(registry, target, 'example', callback)
|
||||
target.dispatchEvent(new Event('example'))
|
||||
assert.equal(hits, 1)
|
||||
first()
|
||||
target.dispatchEvent(new Event('example'))
|
||||
assert.equal(hits, 1)
|
||||
scope.dispose()
|
||||
assert.equal(registry.destroyEvents.size, 0)
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { clickInit, hoverInit, moveInit, Emitter, beginLifecycle, getScope } = await loadModules({
|
||||
clickInit: 'packages/artplayer/src/events/clickInit',
|
||||
hoverInit: 'packages/artplayer/src/events/hoverInit',
|
||||
moveInit: 'packages/artplayer/src/events/moveInit',
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const classes = new Set()
|
||||
const $player = { classList: { add: name => classes.add(name), remove: name => classes.delete(name) } }
|
||||
const $video = {}
|
||||
const calls = []
|
||||
const callbacks = new Map()
|
||||
const art = Object.assign(new Emitter(), {
|
||||
template: { $player, $video },
|
||||
constructor: { DBCLICK_TIME: 300, DBCLICK_FULLSCREEN: true, MOBILE_CLICK_PLAY: false, MOBILE_DBCLICK_PLAY: true },
|
||||
isFocus: false,
|
||||
isInput: false,
|
||||
isLock: false,
|
||||
fullscreen: false,
|
||||
toggle: () => calls.push('toggle'),
|
||||
})
|
||||
beginLifecycle(art)
|
||||
const events = {
|
||||
proxy(target, name, callback) {
|
||||
callbacks.set(name, callback)
|
||||
return () => callbacks.delete(name)
|
||||
},
|
||||
hover(target, enter, leave) {
|
||||
callbacks.set('mouseenter', enter)
|
||||
callbacks.set('mouseleave', leave)
|
||||
},
|
||||
}
|
||||
clickInit(art, events)
|
||||
hoverInit(art, events)
|
||||
moveInit(art, events)
|
||||
return { art, classes, calls, callbacks, dispose: () => getScope(art).dispose() }
|
||||
}
|
||||
|
||||
test('click grouping keeps the inclusive threshold, third-click reset and live settings', (t) => {
|
||||
const f = fixture()
|
||||
let now = 0
|
||||
t.mock.method(Date, 'now', () => now)
|
||||
f.art.on('click', () => f.calls.push('click'))
|
||||
f.art.on('dblclick', () => f.calls.push('dblclick'))
|
||||
const click = f.callbacks.get('click')
|
||||
click(new Event('click'))
|
||||
now = 300
|
||||
click(new Event('click'))
|
||||
assert.equal(f.art.fullscreen, true)
|
||||
now = 301
|
||||
click(new Event('click'))
|
||||
now = 602
|
||||
click(new Event('click'))
|
||||
f.art.constructor.DBCLICK_FULLSCREEN = false
|
||||
click(new Event('click'))
|
||||
assert.equal(f.art.fullscreen, true)
|
||||
assert.deepEqual(f.calls, ['click', 'toggle', 'dblclick', 'click', 'toggle', 'click', 'toggle', 'dblclick'])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
for (const name of ['click', 'dblclick']) {
|
||||
test(`destruction during ${name} stops the remaining playback or fullscreen action`, () => {
|
||||
const f = fixture()
|
||||
f.art.constructor.DBCLICK_TIME = 60000
|
||||
f.art.on(name, f.dispose)
|
||||
f.callbacks.get('click')(new Event('click'))
|
||||
if (name === 'dblclick')
|
||||
f.callbacks.get('click')(new Event('click'))
|
||||
assert.equal(f.art.fullscreen, false)
|
||||
assert.deepEqual(f.calls, name === 'click' ? [] : ['toggle'])
|
||||
f.callbacks.get('click')(new Event('click'))
|
||||
assert.deepEqual(f.calls, name === 'click' ? [] : ['toggle'])
|
||||
})
|
||||
}
|
||||
|
||||
test('click callback errors retain identity and stop the default action', () => {
|
||||
const f = fixture()
|
||||
const failure = new Error('caller failed')
|
||||
const callback = () => {
|
||||
throw failure
|
||||
}
|
||||
f.art.on('click', callback)
|
||||
assert.throws(() => f.callbacks.get('click')(new Event('click')), error => error === failure)
|
||||
assert.deepEqual(f.calls, [])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('click-triggered playback rejection remains handled internally', async () => {
|
||||
const f = fixture()
|
||||
f.art.toggle = () => Promise.reject(new DOMException('interrupted', 'AbortError'))
|
||||
f.callbacks.get('click')(new Event('click'))
|
||||
await setImmediate()
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('nested clicks preserve the historical synchronous grouping and event identity', (t) => {
|
||||
const f = fixture()
|
||||
t.mock.method(Date, 'now', () => 100)
|
||||
const first = new Event('click')
|
||||
const nested = new Event('click')
|
||||
f.art.on('click', (event) => {
|
||||
assert.equal(event, first)
|
||||
f.calls.push('click')
|
||||
f.callbacks.get('click')(nested)
|
||||
})
|
||||
f.art.on('dblclick', event => f.calls.push(event === nested ? 'nested' : 'next'))
|
||||
f.callbacks.get('click')(first)
|
||||
assert.equal(f.art.fullscreen, true)
|
||||
f.callbacks.get('click')(new Event('click'))
|
||||
assert.equal(f.art.fullscreen, false)
|
||||
assert.deepEqual(f.calls, ['click', 'nested', 'toggle', 'next'])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('focus flags retain input-only semantics and disposal removes only owned subscriptions', () => {
|
||||
const f = fixture()
|
||||
const events = []
|
||||
f.art.on('focus', event => events.push(event))
|
||||
const input = { tagName: 'INPUT' }
|
||||
const event = { target: input, composedPath: () => [input, f.art.template.$player] }
|
||||
f.art.emit('document:click', event)
|
||||
assert.equal(f.art.isInput, true)
|
||||
assert.equal(f.art.isFocus, true)
|
||||
assert.equal(events[0], event)
|
||||
input.tagName = 'TEXTAREA'
|
||||
f.art.emit('document:contextmenu', event)
|
||||
assert.equal(f.art.isInput, false)
|
||||
let external = 0
|
||||
f.art.on('document:click', () => external++)
|
||||
f.dispose()
|
||||
f.art.emit('document:click', { target: null, composedPath: () => [] })
|
||||
assert.equal(f.art.isFocus, true)
|
||||
assert.equal(external, 1)
|
||||
assert.equal(events.length, 2)
|
||||
})
|
||||
|
||||
test('hover and move preserve class-before-event ordering and reject stale callbacks', () => {
|
||||
const f = fixture()
|
||||
const states = []
|
||||
const event = new Event('mousemove')
|
||||
f.art.on('hover', (state, original) => states.push([state, original, f.classes.has('art-hover')]))
|
||||
f.art.on('mousemove', original => states.push(['move', original]))
|
||||
f.callbacks.get('mouseenter')(event)
|
||||
f.callbacks.get('mousemove')(event)
|
||||
f.callbacks.get('mouseleave')(event)
|
||||
assert.deepEqual(states, [[true, event, true], ['move', event], [false, event, false]])
|
||||
f.dispose()
|
||||
f.callbacks.get('mouseenter')(event)
|
||||
f.callbacks.get('mousemove')(event)
|
||||
assert.equal(f.classes.size, 0)
|
||||
assert.equal(states.length, 3)
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { GlobalEventSource } from '../../packages/artplayer/src/events/global-types'
|
||||
import type { ViewEvents, ViewHost } from '../../packages/artplayer/src/events/scheduling-types'
|
||||
import type { EventsHost } from '../../packages/artplayer/src/events/types'
|
||||
import Events from '../../packages/artplayer/src/events'
|
||||
import { eventSubscriptions } from '../../packages/artplayer/src/events/subscriptions'
|
||||
|
||||
declare const host: EventsHost
|
||||
declare const view: ViewHost
|
||||
declare const source: GlobalEventSource
|
||||
const events = new Events(host)
|
||||
const target = document.createElement('button')
|
||||
const dispose: () => void = events.proxy(target, 'click', event => event.preventDefault(), null)
|
||||
const disposers: (() => void)[] = events.proxy(target, ['click', 'mouseenter'], { handleEvent: event => event.preventDefault() })
|
||||
events.remove(dispose)
|
||||
disposers.forEach(remove => remove())
|
||||
events.bindGlobalEvents(source)
|
||||
// @ts-expect-error Native registration requires a string name or array.
|
||||
events.proxy(target, 123, () => {})
|
||||
// @ts-expect-error Public hover supplies an Event, not a narrower keyboard event.
|
||||
events.hover(target, (event: KeyboardEvent) => event.code)
|
||||
const on = eventSubscriptions<ViewEvents>(view)
|
||||
on('view', (visible) => {
|
||||
const value: boolean = visible
|
||||
return value
|
||||
})
|
||||
// @ts-expect-error View payloads are boolean.
|
||||
on('view', (visible: string) => visible)
|
||||
// @ts-expect-error The subscription factory only accepts its declared event map.
|
||||
on('resize', () => {})
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { GestureHost } from '../../packages/artplayer/src/input/gesture-types'
|
||||
import type Artplayer from '../../packages/artplayer/types/artplayer'
|
||||
import { gestureController } from '../../packages/artplayer/src/input/gesture-controller'
|
||||
|
||||
declare const art: Artplayer
|
||||
declare const host: GestureHost
|
||||
const gesture = gestureController(host)
|
||||
gesture.start(host.template.$video, new TouchEvent('touchstart'))
|
||||
gesture.move(new TouchEvent('touchmove'))
|
||||
gesture.cancel()
|
||||
// @ts-expect-error Gesture input requires touch coordinates and identifiers.
|
||||
gesture.move(new MouseEvent('mousemove'))
|
||||
art.on('document:touchcancel', (event) => {
|
||||
const original: Event = event
|
||||
return original
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { HotkeyHost } from '../../packages/artplayer/src/input/hotkey-types'
|
||||
import Hotkey from '../../packages/artplayer/src/hotkey'
|
||||
|
||||
declare const host: HotkeyHost & { extension: string }
|
||||
const keys = new Hotkey(host)
|
||||
keys.add('KeyK', function (event) {
|
||||
const extension: string = this.extension
|
||||
const code: string = event.code
|
||||
return { extension, code }
|
||||
}).remove('KeyK', () => {})
|
||||
const same: typeof host = keys.art
|
||||
// @ts-expect-error Keyboard callbacks receive a keyboard event.
|
||||
keys.add('KeyK', (event: MouseEvent) => event.clientX)
|
||||
// @ts-expect-error The callback receiver preserves the host extension type.
|
||||
keys.add('KeyK', function (this: { extension: number }) {
|
||||
return this.extension
|
||||
})
|
||||
export { same }
|
||||
Reference in new issue
Block a user