refactor(core): CORE-17 type input events and own interaction lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 11:24:15 +08:00
1 parent 88aef830df
commit a842058136
72 files changed
+31156 -1219

No files matched your search

+125
View File
@@ -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'])
})
}
+150
View File
@@ -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 })
})
}
+99
View File
@@ -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 })
})
}
+214
View File
@@ -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)
})
}
+175
View File
@@ -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])
})
+151
View File
@@ -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 })
})
}
+112
View File
@@ -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],
])
})
}
+223
View File
@@ -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()
})
+215
View File
@@ -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()
})
+133
View File
@@ -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, [])
})
+168
View File
@@ -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()
})
+201
View File
@@ -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)
})
+161
View File
@@ -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)
})
+29
View File
@@ -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', () => {})
+16
View File
@@ -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
})
+18
View File
@@ -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 }