refactor(core): CORE-02 type emitter maps and fix nested once dispatch

This commit is contained in:
Harvey Zhao committed 2026-09-11 00:03:53 +08:00
1 parent d3c695024a
commit 362c616998
21 files changed
+943 -72

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
import { expect, test } from './fixtures.js'
for (const core of ['published', 'candidate']) {
test(`${core}: emitter event names and once across nested dispatch`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
const result = await page.evaluate(() => {
const emitter = new window.Artplayer.Emitter()
const freshKeys = Object.keys(emitter)
let reservedCalls = 0
let errorName
try {
emitter.on('__proto__', () => reservedCalls++).emit('__proto__').off('__proto__')
}
catch (error) { errorName = error.name }
let nested = false
let onceCalls = 0
emitter.on('value', () => {
if (!nested) {
nested = true
emitter.emit('value')
}
}).once('value', () => onceCalls++)
emitter.emit('value')
return { freshKeys, reservedCalls, errorName, onceCalls }
})
expect(result).toEqual({ freshKeys: [], reservedCalls: core === 'published' ? 0 : 1, errorName: core === 'published' ? 'TypeError' : undefined, onceCalls: core === 'published' ? 2 : 1 })
})
}
+45
View File
@@ -1,6 +1,51 @@
import assert from 'node:assert/strict'
export const emitterContracts = {
'EVENT.lazy-registry': function (Emitter) {
const emitter = new Emitter()
assert.deepEqual(Object.keys(emitter), [])
emitter.emit('empty').off('empty')
assert.equal(Object.getOwnPropertyDescriptor(emitter, 'e').enumerable, true)
assert.deepEqual(Object.keys(emitter.e), [])
assert.equal(typeof emitter.e.hasOwnProperty, 'function')
},
'EVENT.symbol-number-channels': function (Emitter) {
const emitter = new Emitter()
const symbol = Symbol('channel')
const seen = []
emitter.on(symbol, value => seen.push(value)).on(1, value => seen.push(value))
emitter.emit(symbol, 'symbol').emit('1', 'numeric')
emitter.off(symbol).off(1).emit(symbol).emit('1')
assert.deepEqual(seen, ['symbol', 'numeric'])
},
'EVENT.context-removal': function (Emitter) {
const emitter = new Emitter()
const seen = []
function fn() {
seen.push(this)
}
const a = {}
const b = {}
emitter.on('value', fn, a).once('value', fn, b)
const wrapper = emitter.e.value[1].fn
assert.equal(wrapper._, fn)
emitter.off('value', wrapper).emit('value')
assert.deepEqual(seen, [a])
emitter.on('value', fn, b).off('value', fn).emit('value')
assert.deepEqual(seen, [a])
},
'EVENT.once-throw-consumed': function (Emitter) {
const emitter = new Emitter()
const marker = new Error('once failed')
let calls = 0
emitter.once('value', () => {
calls++
throw marker
})
assert.throws(() => emitter.emit('value'), error => error === marker)
emitter.emit('value')
assert.equal(calls, 1)
},
'EVENT.chain-context-arguments': function (Emitter) {
const emitter = new Emitter()
const context = {}
+2 -1
View File
@@ -5,6 +5,7 @@ import path from 'node:path'
import { test } from 'node:test'
import { checkFiles, publishedConsumer } from '../scripts/package-check.mjs'
import { removeConsumer, runtimeConsumer } from '../scripts/package-consumer.mjs'
import { emitterContracts } from './contracts/emitter.js'
test('Package checks reject missing files, missing wildcard exports and internal configuration', () => {
const manifest = { name: 'fixture', main: './dist/main.js', module: './dist/main.mjs', types: './types/main.d.ts', legacy: './dist/legacy.js', exports: { '.': './dist/main.js', './lang/*': './dist/lang/*.js' } }
@@ -19,7 +20,7 @@ test('Package checks reject missing files, missing wildcard exports and internal
test('Actual isolated runtime rejects removed default exports and required files', async () => {
const { dir } = await publishedConsumer()
try {
assert.equal(runtimeConsumer(dir).checks.length, 23)
assert.equal(runtimeConsumer(dir).checks.length, 18 + Object.keys(emitterContracts).length)
const esm = path.join(dir, 'node_modules/artplayer-plugin-chapter/dist/artplayer-plugin-chapter.mjs')
const original = fs.readFileSync(esm)
fs.writeFileSync(esm, 'export const removedDefault = true\n')
+35
View File
@@ -1,3 +1,4 @@
import assert from 'node:assert/strict'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Run the same public contract against each actual implementation.
import { test } from 'node:test'
@@ -12,3 +13,37 @@ for (const { name, Artplayer } of targets) {
for (const [id, verify] of Object.entries(emitterContracts))
test(`${id}: ${name}`, () => verify(Artplayer.Emitter))
}
test('EVENT.prototype-event-names: candidate supports names that collide with Object.prototype', () => {
for (const name of ['__proto__', 'constructor', 'toString', 'hasOwnProperty']) {
assert.throws(() => new targets[0].Artplayer.Emitter().on(name, () => {}), error => error.name === 'TypeError')
for (const { Artplayer } of targets.slice(1)) {
const emitter = new Artplayer.Emitter()
let calls = 0
const fn = () => calls++
emitter.emit(name).off(name)
const prototype = Object.getPrototypeOf(emitter.e)
emitter.on(name, fn).once(name, fn)
emitter.emit(name).emit(name).off(name, fn).emit(name)
assert.equal(calls, 3)
assert.equal(Object.prototype.hasOwnProperty.call(emitter.e, name), false)
assert.equal(Object.getPrototypeOf(emitter.e), prototype)
}
}
})
test('EVENT.once-nested-snapshot: candidate consumes a once registration across nested dispatch snapshots', () => {
for (const [index, { Artplayer }] of targets.entries()) {
const emitter = new Artplayer.Emitter()
let nested = false
let calls = 0
emitter.on('value', () => {
if (!nested) {
nested = true
emitter.emit('value')
}
}).once('value', () => calls++)
emitter.emit('value')
assert.equal(calls, index === 0 ? 2 : 1)
}
})
+42
View File
@@ -0,0 +1,42 @@
import type { Events } from 'artplayer'
import Emitter from '../../packages/artplayer/src/utils/emitter'
const symbol = Symbol('message')
interface Messages {
ready: []
value: [count: number, label?: string]
readonly: readonly [value: number]
[symbol]: [message: string]
}
class TypedEmitter extends Emitter<Messages> {
count = 0
}
const events = new TypedEmitter()
const context = { total: 0 }
const chain: TypedEmitter = events.on('value', function (count, label) {
this.total += count
label?.toUpperCase()
}, context).once('ready', () => {}).emit('value', 1).off('ready')
events.emit(symbol, 'message')
events.emit('readonly', 1)
const playerEvents = new Emitter<Events & Record<string, unknown[]>>()
playerEvents.emit('setBar', 'loaded', 0.5)
playerEvents.emit('custom:event', { extra: true })
// @ts-expect-error Known events retain their payload types with open custom names.
playerEvents.emit('setBar', 'invalid', 0.5)
// @ts-expect-error A declared map rejects unknown names.
events.emit('missing')
// @ts-expect-error Payload types are correlated with the event name.
events.emit('value', 'one')
// @ts-expect-error Required payloads cannot be omitted.
events.emit('value')
// @ts-expect-error Empty events reject extra values.
events.emit('ready', 1)
// @ts-expect-error Listener arguments must match the event.
events.on('value', (count: string) => count)
// @ts-expect-error Explicit receiver types must agree with the provided context.
events.on('ready', contextListener, { total: 'zero' })
function contextListener(this: { total: number }) {
return this.total
}
void chain