import { expect, test } from './fixtures.js' async function setup(page) { await page.goto('/test/player.html?core=candidate&chapter=published') await page.evaluate(() => { window.settingCalls = [] window.settingHotkeys = [] window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, hotkey: true, setting: true, fullscreenWeb: true, playbackRate: false, aspectRatio: false, flip: false, subtitleOffset: false, settings: [ { name: 'quality', html: 'Quality', tooltip: 'Auto', selector: [ { name: 'auto', html: 'Auto', default: true }, { name: 'hd', html: 'HD' }, { name: 'more', html: 'More', selector: [{ name: 'ultra', html: 'Ultra' }, { name: 'cinema', html: 'Cinema' }] }, ], onSelect(item, element, event) { window.settingCalls.push(['select', item.name, this === window.art, element === item.$item, event.type]) return item.html } }, { name: 'loop', html: 'Loop', switch: false, onSwitch(item, element, event) { window.settingCalls.push(['switch', this === window.art, element === item.$item, event.type]) return !item.switch } }, { name: 'offset', html: 'Offset', range: [0, -10, 10, 1], onChange(item, element, event) { window.settingCalls.push(['input', item.range[0], this === window.art, element === item.$item, event.type]) return `${item.range[0]}s` }, onRange(item, element, event) { window.settingCalls.push(['change', item.range[0], this === window.art, element === item.$item, event.type]) return `${item.range[0]}s` } }, { name: 'action', html: 'Action', onClick(item, element, event) { window.settingCalls.push(['action', this === window.art, element === item.$item, event.type]) return 'Clicked' } }, ], }) window.art.on('hotkey', event => window.settingHotkeys.push(event.code)) }) await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true) await page.locator('.art-control-setting').focus() } const row = (page, name) => page.locator(`.art-setting-item[data-name="${name}"]`) const button = page => page.locator('.art-control-setting') for (const unavailable of ['removed', 'hidden', 'disabled']) { test(`candidate: setting exit falls back to the player when its button is ${unavailable}`, async ({ page }) => { await setup(page) await page.keyboard.press('Enter') await expect(row(page, 'quality')).toBeFocused() await page.evaluate((unavailable) => { if (unavailable === 'removed') window.art.controls.remove('setting') else if (unavailable === 'hidden') window.art.controls.setting.style.visibility = 'hidden' else window.art.controls.setting.setAttribute('aria-disabled', 'true') }, unavailable) await page.keyboard.press('Escape') await expect(page.locator('.art-settings')).toHaveCount(1) await expect(page.locator('.art-settings')).toBeHidden() await expect(page.locator('.art-video-player')).toBeFocused() }) } test('candidate: setting navigation ignores hidden rows and inert descendants', async ({ page }) => { await setup(page) await page.keyboard.press('Enter') await page.evaluate(() => { window.art.setting.find('loop').$item.style.visibility = 'hidden' window.art.setting.find('offset').$item.setAttribute('inert', '') }) await page.keyboard.press('ArrowDown') await expect(row(page, 'action')).toBeFocused() await page.keyboard.press('ArrowUp') await expect(row(page, 'quality')).toBeFocused() }) test('candidate: long setting panels observe live button availability on every navigation', async ({ page }, testInfo) => { await setup(page) await page.evaluate(() => { const setting = window.art.setting for (const name of ['quality', 'loop', 'offset', 'action']) setting.remove(name) for (let index = 0; index < 40; index++) setting.add({ name: `row-${index}`, html: `` }) }) await page.keyboard.press('Enter') await expect(page.locator('#setting-button-0')).toBeFocused() const measurements = [] async function navigate(key, expected) { await page.evaluate(() => { const panel = window.art.setting.cache.get(window.art.setting.active) const query = panel.querySelectorAll const style = window.getComputedStyle const counts = { queries: 0, styles: 0 } panel.querySelectorAll = function (...args) { counts.queries++ return query.apply(this, args) } window.getComputedStyle = function (element, ...args) { if (panel.contains(element)) counts.styles++ return style.call(this, element, ...args) } window.finishSettingMeasurement = () => { delete panel.querySelectorAll window.getComputedStyle = style delete window.finishSettingMeasurement return counts } }) try { await page.keyboard.press(key) } finally { measurements.push({ key, expected, ...await page.evaluate(() => window.finishSettingMeasurement()) }) } await expect(page.locator(`#setting-button-${expected}`)).toBeFocused() } await navigate('ArrowDown', 1) await page.evaluate(() => { document.querySelector('#setting-button-2').style.visibility = 'hidden' document.querySelector('#setting-button-3').closest('.art-setting-item').setAttribute('inert', '') document.querySelector('#setting-button-4').disabled = true document.querySelector('#setting-button-5').tabIndex = -1 }) await navigate('ArrowDown', 6) await navigate('a', 6) await navigate('End', 39) await page.evaluate(() => { document.querySelector('#setting-button-2').style.visibility = '' document.querySelector('#setting-button-3').closest('.art-setting-item').removeAttribute('inert') document.querySelector('#setting-button-4').disabled = false document.querySelector('#setting-button-5').removeAttribute('tabindex') window.art.setting.remove('row-1') }) await navigate('Home', 0) await navigate('ArrowDown', 2) await navigate('ArrowDown', 3) await navigate('ArrowDown', 4) await navigate('ArrowDown', 5) await testInfo.attach('setting-navigation-work', { contentType: 'application/json', body: JSON.stringify(measurements, null, 2) }) }) test('candidate: setting keyboard enters nested panels and returns to the originating item', async ({ page }, testInfo) => { await setup(page) await page.evaluate(() => { window.art.fullscreenWeb = true }) await expect(button(page)).toBeFocused() await page.keyboard.press('Enter') await expect(row(page, 'quality')).toBeFocused() await expect(button(page)).toHaveAttribute('aria-expanded', 'true') await expect(page.locator('.art-setting-panel.art-current')).toHaveAttribute('aria-label', 'Settings') await page.keyboard.press('ArrowRight') await expect(row(page, 'auto')).toBeFocused() await expect(row(page, 'auto')).toHaveAttribute('aria-current', 'true') await page.keyboard.press('ArrowDown') await expect(row(page, 'hd')).toBeFocused() expect(await page.evaluate(() => window.settingCalls)).toEqual([]) await page.keyboard.press('Enter') await expect(row(page, 'quality')).toBeFocused() expect(await page.evaluate(() => window.settingCalls)).toEqual([['select', 'hd', true, true, 'click']]) await expect(row(page, 'quality')).toContainText('HD') await page.keyboard.press('ArrowRight') await expect(row(page, 'hd')).toBeFocused() await expect(row(page, 'hd')).toHaveAttribute('aria-current', 'true') await page.keyboard.press('End') await expect(row(page, 'more')).toBeFocused() await page.keyboard.press('ArrowRight') await expect(row(page, 'ultra')).toBeFocused() await page.keyboard.press('ArrowLeft') await expect(row(page, 'more')).toBeFocused() await page.keyboard.press('Home') await expect(page.locator('.art-setting-panel.art-current .art-setting-item-back')).toBeFocused() await expect(page.locator('.art-setting-panel.art-current .art-setting-item-back')).toHaveAttribute('aria-label', 'Back: Quality') await page.keyboard.press('Space') await expect(row(page, 'quality')).toBeFocused() await page.locator('.art-video-player').screenshot({ path: testInfo.outputPath('setting-keyboard.png') }) await page.keyboard.press('Escape') await expect(button(page)).toBeFocused() await expect(button(page)).toHaveAttribute('aria-expanded', 'false') expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(true) expect(await page.evaluate(() => window.settingHotkeys)).toEqual([]) await page.keyboard.press('Escape') await expect.poll(() => page.evaluate(() => window.art.fullscreenWeb)).toBe(false) }) test('candidate: setting switches and native ranges keep callbacks and avoid player shortcuts', async ({ page }) => { await setup(page) await page.keyboard.press('ArrowDown') await page.keyboard.press('ArrowDown') await expect(row(page, 'loop')).toBeFocused() await expect(row(page, 'loop')).toHaveAttribute('role', 'switch') await expect(row(page, 'loop')).toHaveAttribute('aria-checked', 'false') await page.keyboard.down('Space') await page.keyboard.down('Space') await page.keyboard.up('Space') await expect(row(page, 'loop')).toHaveAttribute('aria-checked', 'true') expect(await page.evaluate(() => window.settingCalls)).toEqual([['switch', true, true, 'click']]) await page.keyboard.press('Tab') const range = row(page, 'offset').locator('input') await expect(range).toBeFocused() await expect(range).toHaveAttribute('aria-label', 'Offset') await page.keyboard.press('ArrowRight') await expect(range).toHaveValue('1') await expect.poll(() => page.evaluate(() => window.settingCalls.filter(item => ['input', 'change'].includes(item[0])))).toEqual([ ['input', 1, true, true, 'input'], ['change', 1, true, true, 'change'], ]) await page.evaluate(() => { window.art.setting.find('offset').html = 'Subtitle delay' }) await expect(range).toHaveAttribute('aria-label', 'Subtitle delay') expect(await page.evaluate(() => [window.art.currentTime, window.art.volume, window.art.video.paused])).toEqual([0, 0.7, true]) expect(await page.evaluate(() => window.settingHotkeys)).toEqual([]) await page.keyboard.press('Escape') await expect(button(page)).toBeFocused() }) test('candidate: setting Tab leaves without trapping focus and pointer opening keeps its old focus path', async ({ page }) => { await setup(page) await page.evaluate(() => { window.settingFocusOrder = [] window.art.template.$setting.addEventListener('focusin', event => window.settingFocusOrder.push(event.target.dataset.name)) }) await page.keyboard.press('ArrowUp') await expect(row(page, 'action')).toBeFocused() expect(await page.evaluate(() => window.settingFocusOrder)).toEqual(['action']) await page.keyboard.press('Tab') await expect.poll(() => page.evaluate(() => window.art.setting.show)).toBe(false) await expect(row(page, 'action')).not.toBeFocused() await button(page).click() await expect.poll(() => page.evaluate(() => window.art.setting.show)).toBe(true) await expect(row(page, 'quality')).not.toBeFocused() await row(page, 'action').click() expect(await page.evaluate(() => window.settingCalls)).toEqual([['action', true, true, 'click']]) }) test('candidate: setting update, failed replacement and removal recover visible focus', async ({ page }) => { await setup(page) await page.keyboard.press('ArrowUp') await expect(row(page, 'action')).toBeFocused() await page.evaluate(() => window.art.setting.update({ name: 'action', html: 'Updated' })) await expect(row(page, 'action')).toBeFocused() await expect(row(page, 'action')).toHaveText('Updated') await page.evaluate(() => { const setting = window.art.setting const create = setting.createItem setting.createItem = function (...args) { create.apply(this, args) throw new Error('forced keyboard replacement failure') } try { setting.update({ name: 'action', html: 'Failed' }) } catch (error) { window.settingUpdateFailure = error.message } finally { setting.createItem = create } }) expect(await page.evaluate(() => window.settingUpdateFailure)).toBe('forced keyboard replacement failure') await expect(row(page, 'action')).toBeFocused() await expect(row(page, 'action')).toHaveText('Updated') await page.keyboard.press('Enter') expect(await page.evaluate(() => window.settingCalls)).toEqual([['action', true, true, 'click']]) await page.evaluate(() => window.art.setting.remove('action')) await expect(row(page, 'quality')).toBeFocused() }) test('candidate: suspended settings ignore keyboard callbacks and restored items resume', async ({ page }) => { await setup(page) await page.keyboard.press('ArrowUp') await page.evaluate(() => { const target = { name: 'action' } Object.defineProperty(target, 'html', { enumerable: true, get() { const item = window.art.setting.find('action').$item item.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })) item.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true })) throw new Error('resume old keyboard item') } }) try { window.art.setting.update(target) } catch {} }) expect(await page.evaluate(() => window.settingCalls)).toEqual([]) expect(await page.evaluate(() => window.art.currentTime)).toBe(0) await expect(row(page, 'action')).toBeFocused() await page.keyboard.press('Enter') expect(await page.evaluate(() => window.settingCalls)).toEqual([['action', true, true, 'click']]) }) test('candidate: native setting buttons retain their role, click path and keyboard navigation', async ({ page }) => { await setup(page) await page.evaluate(() => { window.art.setting.add({ name: 'native', html: '', onClick(item, element, event) { window.settingCalls.push(['native', this === window.art, element === item.$item, event.type]) return 'Done' } }) }) await page.keyboard.press('ArrowUp') const native = page.locator('#native-setting') await expect(native).toBeFocused() expect(await row(page, 'native').getAttribute('role')).toBeNull() await page.keyboard.press('Space') expect(await page.evaluate(() => window.settingCalls)).toEqual([['native', true, true, 'click']]) expect(await page.evaluate(() => window.art.video.paused)).toBe(true) await page.keyboard.press('ArrowUp') await expect(row(page, 'action')).toBeFocused() expect(await page.evaluate(() => window.settingHotkeys)).toEqual([]) }) test('candidate: destroying during moved focus restoration cancels fullscreen notifications', async ({ page }) => { await setup(page) await page.evaluate(() => { window.modeEvents = [] window.art.on('fullscreenWeb', value => window.modeEvents.push(['fullscreenWeb', value])) window.art.on('resize', () => window.modeEvents.push(['resize'])) window.art.controls.setting.addEventListener('focus', () => window.art.destroy(false), { once: true }) window.art.fullscreenWeb = true }) expect(await page.evaluate(() => window.art.isDestroy)).toBe(true) expect(await page.evaluate(() => window.modeEvents)).toEqual([]) })