import assert from 'node:assert/strict' // eslint-disable-next-line test/no-import-node-test -- Controlled clock/host contracts use Node's runner. import test from 'node:test' import { adsEnvironment, adsImplementations } from './helpers/ads.js' const implementations = await adsImplementations() const skips = host => host.calls.filter(call => call[0] === 'emit' && call[1] === 'artplayerPluginAds:skip') const clicks = host => host.calls.filter(call => call[0] === 'emit' && call[1] === 'artplayerPluginAds:click') const contentPlays = host => host.calls.filter(call => call[0] === 'content.play') for (const { name, factory } of implementations.filter(item => !item.historical)) { test(`${name}: historical default and callable entry share one factory and synchronous contract`, (t) => { const env = adsEnvironment(t) assert.equal(factory.default, factory) const host = env.host() const result = factory.default({ html: 'ad' })(host.art) assert.equal(result.name, 'artplayerPluginAds') assert.equal(result.skip(), undefined) host.start() assert.equal(host.node(), undefined) assert.equal(env.timers.size, 0) }) } for (const { name, factory } of implementations) { test(`${name}: lazy synchronous factory starts once after ready and either playback event`, (t) => { const env = adsEnvironment(t) const host = env.host() const result = factory({ html: 'ad' })(host.art) assert.deepEqual(Object.keys(result), ['name', 'skip', 'pause', 'play']) assert.equal(result.name, 'artplayerPluginAds') assert.equal(host.node(), undefined) assert.equal(env.timers.size, 0) host.art.emit('play') assert.equal(host.node(), undefined) host.art.emit('ready').emit('video:playing') const node = host.node() assert(node) assert.equal(host.art.template.$ads, node) assert.equal(host.node('html').innerHTML, 'ad') assert.equal(host.art.playing, false) host.art.emit('play').emit('video:playing') assert.equal(host.node(), node) assert.equal(env.timers.size, 1) assert.equal(host.calls.filter(call => call[0] === 'content.pause').length, 1) }) test(`${name}: actual validator keeps numeric durations and complete shallow i18n`, (t) => { const env = adsEnvironment(t) assert.throws(() => factory({ totalDuration: '10' })(env.host().art), /totalDuration/) assert.throws(() => factory({ playDuration: '5' })(env.host().art), /playDuration/) assert.throws(() => factory({ html: 1 })(env.host().art), /html/) assert.throws(() => factory({ i18n: { close: 'close' } })(env.host().art), /i18n/) const host = env.host() factory()(host.art) assert.equal(host.normalized.totalDuration, 10) assert.equal(host.normalized.playDuration, 5) assert.equal(host.normalized.muted, false) assert.equal(host.normalized.html, '') assert.equal(host.normalized.video, '') }) test(`${name}: video wins over HTML and metadata starts media and countdown`, (t) => { const env = adsEnvironment(t) const host = env.host() factory({ html: 'fallback', video: 'ad.mp4', muted: true })(host.art) host.start() assert.equal(host.node('html'), undefined) assert.equal(host.node('video').tagName, 'VIDEO') assert.equal(host.node('video').muted, true) assert.equal(env.timers.size, 0) host.fire('video', 'loadedmetadata') assert.equal(env.timers.size, 1) assert.equal(host.node('video').paused, false) assert.equal(host.node('timer').style.display, 'flex') assert.equal(host.node('control').style.display, 'flex') assert.equal(host.node('loading').style.display, 'none') assert.equal(host.calls.filter(call => call[0] === 'ad.play').length, 1) }) test(`${name}: each active second counts down and total duration restores content`, (t) => { const env = adsEnvironment(t) const host = env.host() factory({ html: 'ad', playDuration: 2, totalDuration: 3 })(host.art) host.start() assert.equal(host.node('close').innerHTML, '2秒后可关闭广告') assert.equal(host.node('countdown').innerHTML, '3秒') env.tick(999) assert.equal(host.node('countdown').innerHTML, '3秒') env.tick(1) assert.equal(host.node('close').innerHTML, '1秒后可关闭广告') assert.equal(host.node('countdown').innerHTML, '2秒') env.tick(1000) assert.equal(host.node('close').innerHTML, '关闭广告') assert.equal(contentPlays(host).length, 0) env.tick(1000) assert.equal(host.node('countdown').innerHTML, '0秒') assert.equal(host.node().style.display, 'none') assert.equal(skips(host).length, 1) assert.equal(skips(host)[0][2], host.normalized) assert.equal(contentPlays(host).length, 1) assert.equal(env.timers.size, 0) }) test(`${name}: pause/play control the countdown without changing ad video state`, (t) => { const env = adsEnvironment(t) const host = env.host() const plugin = factory({ video: 'ad.mp4', totalDuration: 4 })(host.art) host.start() host.fire('video', 'loadedmetadata') env.tick(1000) assert.equal(plugin.pause(), undefined) env.tick(5000) assert.equal(host.node('countdown').innerHTML, '3秒') assert.equal(host.node('video').paused, false) assert.equal(plugin.play(), undefined) env.tick(1000) assert.equal(host.node('countdown').innerHTML, '2秒') assert.equal(host.calls.filter(call => call[0] === 'ad.play').length, 1) }) test(`${name}: button obeys positive threshold while public skip bypasses it`, (t) => { const env = adsEnvironment(t) const host = env.host() const plugin = factory({ html: 'ad', playDuration: 2, totalDuration: 5 })(host.art) host.start() host.fire('close', 'click') env.tick(1000) host.fire('close', 'click') assert.equal(skips(host).length, 0) assert.equal(plugin.skip(), undefined) assert.equal(skips(host).length, 1) const second = env.host() factory({ html: 'second', playDuration: 2, totalDuration: 5 })(second.art) second.start() env.tick(2000) second.fire('close', 'click') assert.equal(skips(second).length, 1) }) test(`${name}: non-skippable button stays hidden and automatic completion still occurs`, (t) => { const env = adsEnvironment(t) const host = env.host() factory({ html: 'ad', playDuration: 2, totalDuration: 2 })(host.art) host.start() assert.equal(host.node('close').style.display, 'none') env.tick(2000) assert.equal(skips(host).length, 1) }) test(`${name}: translations preserve first-placeholder substitution and normalized payload`, (t) => { const env = adsEnvironment(t) const host = env.host() const i18n = { close: 'skip', countdown: '%s seconds %s', detail: 'visit', canBeClosed: 'wait %s' } factory({ html: 'ad', playDuration: 1, totalDuration: 3, i18n })(host.art) host.start() assert.equal(host.node('countdown').innerHTML, '3 seconds %s') assert.equal(host.node('close').innerHTML, 'wait 1') env.tick(1000) assert.equal(host.node('close').innerHTML, 'skip') host.fire('close', 'click') assert.equal(skips(host)[0][2].i18n, i18n) }) test(`${name}: ad/detail clicks preserve URL and option payload; unlinked ad still emits`, (t) => { const env = adsEnvironment(t) const host = env.host() factory({ html: '', url: 'https://example.invalid/ad' })(host.art) host.start() host.fire('html', 'click') host.fire('detail', 'click') assert.deepEqual(env.opened, ['https://example.invalid/ad', 'https://example.invalid/ad']) assert.equal(clicks(host).length, 2) assert(clicks(host).every(call => call[2] === host.normalized)) const second = env.host() factory({ html: 'unlinked' })(second.art) second.start() assert.equal(second.node('detail').style.display, 'none') second.fire('html', 'click') assert.equal(clicks(second).length, 1) assert.equal(env.opened.length, 2) }) test(`${name}: mute applies only to the ad and toggles existing icons`, (t) => { const env = adsEnvironment(t) const host = env.host() factory({ video: 'ad.mp4', muted: true })(host.art) host.start() assert.equal(host.nodes.get('volume').style.display, 'none') assert.equal(host.nodes.get('volumeClose').style.display, 'inline-flex') host.fire('muted', 'click') assert.equal(host.node('video').muted, false) assert.equal(host.nodes.get('volume').style.display, 'inline-flex') assert.equal(host.nodes.get('volumeClose').style.display, 'none') host.fire('muted', 'click') assert.equal(host.node('video').muted, true) }) test(`${name}: document visibility pauses and resumes only the countdown`, (t) => { const env = adsEnvironment(t) const host = env.host() factory({ html: 'ad', totalDuration: 4 })(host.art) host.start() env.tick(1000) host.visibility(true) env.tick(5000) assert.equal(host.node('countdown').innerHTML, '3秒') host.visibility(false) env.tick(1000) assert.equal(host.node('countdown').innerHTML, '2秒') }) test(`${name}: media error before metadata restores content and retains skip order`, (t) => { const env = adsEnvironment(t) const host = env.host() factory({ video: 'missing.mp4' })(host.art) host.start() host.calls.length = 0 host.fire('video', 'error') assert.deepEqual(host.calls.map(call => call.slice(0, 2)), [ ['content.play', false], ['emit', 'play'], ['ad.pause'], ['style', 'artplayer-plugin-ads'], ['emit', 'artplayerPluginAds:skip'], ]) assert.equal(host.node().style.display, 'none') assert.equal(env.timers.size, 0) }) test(`${name}: separate factories keep instance timers and event options independent`, (t) => { const env = adsEnvironment(t) const first = env.host() const second = env.host() const firstPlugin = factory({ html: 'first', totalDuration: 2 })(first.art) factory({ html: 'second', totalDuration: 3 })(second.art) first.start() second.start() firstPlugin.pause() env.tick(3000) assert.equal(skips(first).length, 0) assert.equal(first.node('countdown').innerHTML, '2秒') assert.equal(skips(second).length, 1) assert.equal(skips(second)[0][2], second.normalized) firstPlugin.play() env.tick(2000) assert.equal(skips(first).length, 1) assert.equal(skips(first)[0][2], first.normalized) }) test(`${name}: initial input is read at attach and completed preroll stays hidden after restart`, (t) => { const env = adsEnvironment(t) const host = env.host() const option = { html: 'before', totalDuration: 1 } const attach = factory(option) option.html = 'at attach' attach(host.art) option.html = 'after attach' host.start() assert.equal(host.node('html').innerHTML, 'at attach') env.tick(1000) const root = host.node() host.art.emit('restart', 'second.mp4').emit('ready').emit('play').emit('video:playing') assert.equal(host.node(), root) assert.equal(root.style.display, 'none') assert.equal(skips(host).length, 1) assert.equal(env.timers.size, 0) }) test(`${name}: normalized event options remain live for subsequent countdown ticks`, (t) => { const env = adsEnvironment(t) const host = env.host() factory({ html: 'ad', totalDuration: 10 })(host.art) host.art.on('artplayerPluginAds:click', (option) => { option.totalDuration = 2 }) host.start() host.fire('html', 'click') env.tick(2000) assert.equal(skips(host).length, 1) assert.equal(host.node('countdown').innerHTML, '0秒') }) } // Freeze legacy defects separately; migrated candidates must not inherit these assertions. for (const { name, factory } of implementations.filter(item => item.historical)) { test(`${name} historical: duplicate play starts parallel countdown chains`, (t) => { const env = adsEnvironment(t) const host = env.host() const plugin = factory({ html: 'ad', totalDuration: 10 })(host.art) host.start() plugin.play() assert.equal(env.timers.size, 2) env.tick(1000) assert.equal(host.node('countdown').innerHTML, '8秒') plugin.pause() assert.equal(env.timers.size, 1) }) test(`${name} historical: skip leaves a queued callback and emits completion again`, (t) => { const env = adsEnvironment(t) const host = env.host() const plugin = factory({ html: 'ad', totalDuration: 1 })(host.art) host.start() plugin.skip() assert.equal(env.timers.size, 1) env.tick(1000) assert.equal(skips(host).length, 2) plugin.skip() assert.equal(skips(host).length, 3) }) test(`${name} historical: destroy leaves countdown intent that requests content play`, (t) => { const env = adsEnvironment(t) const host = env.host() factory({ html: 'ad', totalDuration: 1 })(host.art) host.start() host.art.destroy() assert.equal(env.timers.size, 1) env.tick(1000) assert.deepEqual(contentPlays(host), [['content.play', true]]) }) test(`${name} historical: zero wait displays close immediately but rejects the first click`, (t) => { const env = adsEnvironment(t) const host = env.host() factory({ html: 'ad', playDuration: 0, totalDuration: 3 })(host.art) host.start() assert.equal(host.node('close').innerHTML, '关闭广告') host.fire('close', 'click') assert.equal(skips(host).length, 0) env.tick(1000) host.fire('close', 'click') assert.equal(skips(host).length, 1) }) test(`${name} historical: skip before initialization plays content then throws`, (t) => { const env = adsEnvironment(t) const host = env.host() const plugin = factory({ html: 'ad' })(host.art) assert.throws(() => plugin.skip(), /undefined/) assert.equal(contentPlays(host).length, 1) }) test(`${name} historical: reusing one factory changes the first instance event payload identity`, (t) => { const env = adsEnvironment(t) const attach = factory({ html: 'shared' }) const first = env.host() const firstPlugin = attach(first.art) first.start() const firstOption = first.normalized const second = env.host() attach(second.art) second.start() firstPlugin.skip() assert.notEqual(firstOption, second.normalized) assert.equal(skips(first)[0][2], second.normalized) }) test(`${name} historical: repeated metadata schedules and starts the ad twice`, (t) => { const env = adsEnvironment(t) const host = env.host() factory({ video: 'ad.mp4' })(host.art) host.start() host.fire('video', 'loadedmetadata') host.fire('video', 'loadedmetadata') assert.equal(env.timers.size, 2) assert.equal(host.calls.filter(call => call[0] === 'ad.play').length, 2) }) test(`${name} historical: play before initialization schedules a callback against missing DOM`, (t) => { const env = adsEnvironment(t) const host = env.host() const plugin = factory({ html: 'ad' })(host.art) plugin.play() assert.equal(env.timers.size, 1) assert.throws(() => env.tick(1000), /null|undefined/) }) }