import assert from 'node:assert/strict' // eslint-disable-next-line test/no-import-node-test -- Candidate lifecycle assertions are also run against the frozen source to prove the fixes. import test from 'node:test' import { ambilightCandidate, ambilightEnvironment, ambilightHistorical } from './helpers/ambilight.js' const implementation = await ambilightCandidate() test('Ambilight candidate matches all historical normal grid, sample order, timing and public return observations', async () => { const observations = [] for (const source of [...await ambilightHistorical(), implementation]) { const env = await ambilightEnvironment(source) const result = env.factory()(env.art) const grid = env.parent.children[0] env.art.emit('ready') env.art.playing = true env.frame(99) assert.equal(env.draws.length, 0) env.frame(100) assert.equal(env.draws.length, 9) env.frame(199) assert.equal(env.draws.length, 9) observations.push({ name: result.name, grid: { className: grid.className, style: grid.style }, items: grid.children.map(child => child.style), samples: env.draws.map(args => [...args.slice(1)]), start: result.start(), stop: result.stop(), pending: env.frames.size }) } for (const observed of observations.slice(1)) assert.deepEqual(observed, observations[0]) }) test('Ambilight candidate tracks RAF zero without duplicate work and cancels it', async () => { const env = await ambilightEnvironment(implementation, { firstFrame: 0 }) const result = env.factory()(env.art) result.start() result.start() assert.equal(env.frames.size, 1) result.stop() assert.equal(env.frames.size, 0) }) test('Ambilight candidate core destruction releases its view, canvas and listeners and makes escaped methods inert', async () => { const env = await ambilightEnvironment(implementation) const result = env.factory()(env.art) env.art.emit('ready') env.art.destroy() result.start() result.stop() env.art.emit('ready') env.art.destroy() assert.equal(env.frames.size, 0) assert.deepEqual(env.parent.children, [env.video]) assert.equal(env.nodes.find(node => node.tag === 'canvas').width, 0) assert.equal(env.listeners.get('ready')?.size || 0, 0) assert.equal(env.listeners.get('destroy')?.size || 0, 0) }) test('Ambilight candidate recovers from canvas read/draw failure without wedging its loop', async () => { for (const kind of ['read', 'draw']) { const env = await ambilightEnvironment(implementation) const result = env.factory()(env.art) result.start() env.art.playing = true const failure = Object.assign(new Error(kind), { name: kind === 'read' ? 'SecurityError' : 'InvalidStateError' }) if (kind === 'read') env.setReadError(failure) else env.setDrawError(failure) assert.doesNotThrow(() => env.frame(100)) assert.equal(env.frames.size, 1) env.setReadError(null) env.setDrawError(null) env.frame(200) assert.equal(env.draws.length, 10) assert.equal(env.parent.children[0].children[8].style.backgroundColor, 'rgb(10, 20, 30)') env.art.destroy() } }) test('Ambilight candidate skips unavailable contexts and invalid dimensions while retaining a recoverable loop', async () => { const missing = await ambilightEnvironment(implementation, { noContext: true }) missing.art.playing = true missing.time(100) assert.doesNotThrow(() => missing.factory()(missing.art).start()) assert.equal(missing.frames.size, 1) missing.art.destroy() for (const width of [0, -1, Number.NaN, Infinity]) { const env = await ambilightEnvironment(implementation) env.video.videoWidth = width env.art.playing = true env.time(100) const result = env.factory()(env.art) result.start() assert.equal(env.draws.length, 0) env.video.videoWidth = 300 env.frame(200) assert.equal(env.draws.length, 9) env.art.destroy() } }) test('Ambilight candidate stops in-progress sampling after reentrant stop or core destruction', async () => { for (const operation of ['stop', 'destroy']) { let result const env = await ambilightEnvironment(implementation, { onDraw() { if (operation === 'stop') result.stop() else env.art.destroy() } }) result = env.factory()(env.art) const grid = env.parent.children[0] result.start() env.art.playing = true env.frame(100) assert.equal(env.draws.length, 1) assert.equal(grid.children[0].style.backgroundColor, undefined) assert.equal(env.frames.size, 0) env.art.destroy() } }) test('Ambilight candidate stop/start reentrancy defers the replacement frame and discards obsolete colors', async () => { let result let once = true const env = await ambilightEnvironment(implementation, { onDraw() { if (!once) return once = false result.stop() result.start() } }) result = env.factory()(env.art) result.start() env.art.playing = true env.frame(100) assert.equal(env.draws.length, 1) assert.equal(env.frames.size, 1) assert.equal(env.parent.children[0].children[0].style.backgroundColor, undefined) env.frame(200) assert.equal(env.draws.length, 10) env.art.destroy() }) test('Ambilight candidate rolls back inserted views when setup throws', async () => { const env = await ambilightEnvironment(implementation) const failure = new Error('style setup failed') env.art.constructor.utils.setStyles = () => { throw failure } assert.throws(() => env.factory()(env.art), error => error === failure) assert.deepEqual(env.parent.children, [env.video]) assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0) }) test('Ambilight candidate late attachment to a destroyed host allocates no view or canvas', async () => { const env = await ambilightEnvironment(implementation) env.art.destroy() const count = env.nodes.length const result = env.factory()(env.art) result.start() assert.equal(env.nodes.length, count) assert.equal(env.frames.size, 0) assert.equal(env.listeners.size, 0) }) test('Ambilight candidate releases canvas and view when context acquisition throws', async () => { const failure = new Error('context acquisition failed') const env = await ambilightEnvironment(implementation, { onContext() { throw failure } }) assert.throws(() => env.factory()(env.art), error => error === failure) assert.deepEqual(env.parent.children, [env.video]) const canvas = env.nodes.find(node => node.tag === 'canvas') assert.equal(canvas.width, 0) assert.equal(canvas.height, 0) assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0) }) test('Ambilight candidate rolls back partial subscription while preserving the setup error', async () => { const env = await ambilightEnvironment(implementation) const failure = new Error('ready subscription failed') const on = env.art.on env.art.on = (name, callback) => { on(name, callback) if (name === 'ready') throw failure return env.art } assert.throws(() => env.factory()(env.art), error => error === failure) assert.deepEqual(env.parent.children, [env.video]) assert.equal(env.nodes.find(node => node.tag === 'canvas').width, 0) assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0) }) test('Ambilight candidate attempts every release even when host listener cleanup throws', async () => { const env = await ambilightEnvironment(implementation) const result = env.factory()(env.art) result.start() const failure = new Error('host off failed') const off = env.art.off env.art.off = (...args) => { off(...args) throw failure } assert.throws(() => env.art.destroy(), error => error === failure) result.start() assert.equal(env.frames.size, 0) assert.deepEqual(env.parent.children, [env.video]) assert.equal(env.nodes.find(node => node.tag === 'canvas').width, 0) assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0) }) test('Ambilight candidate ignores a cancelled stale callback without losing its replacement frame', async () => { const env = await ambilightEnvironment(implementation) const result = env.factory()(env.art) result.start() const stale = [...env.frames.values()][0] result.stop() result.start() const replacement = [...env.frames.keys()][0] stale(100) assert.deepEqual([...env.frames.keys()], [replacement]) result.stop() assert.equal(env.frames.size, 0) env.art.destroy() }) test('Ambilight candidate construction-time destruction releases partially initialized resources', async () => { for (const boundary of ['style', 'context', 'element', 'subscription']) { const env = await ambilightEnvironment(implementation, { onContext() { if (boundary === 'context') env.art.destroy() } }) if (boundary === 'style') { const original = env.art.constructor.utils.setStyles env.art.constructor.utils.setStyles = (...args) => { env.art.destroy() return original(...args) } } if (boundary === 'element') { const original = env.art.constructor.utils.createElement env.art.constructor.utils.createElement = (...args) => { env.art.destroy() return original(...args) } } if (boundary === 'subscription') { const original = env.art.on env.art.on = (...args) => { const result = original(...args) env.art.destroy() return result } } const result = env.factory()(env.art) result.start() assert.deepEqual(env.parent.children, [env.video], boundary) assert.equal(env.frames.size, 0, boundary) assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0, boundary) for (const canvas of env.nodes.filter(node => node.tag === 'canvas')) assert.equal(canvas.width, 0) } }) test('Ambilight candidate observes destruction from option getters before allocating resources', async () => { const env = await ambilightEnvironment(implementation) const count = env.nodes.length const result = env.factory({ get blur() { env.art.destroy() return '50px' } })(env.art) result.start() assert.equal(env.nodes.length, count) assert.equal(env.frames.size, 0) assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0) }) test('Ambilight candidate keeps normal styles, special frequency values, stop colors and synchronous return shape', async () => { for (const [frequency, draws] of [[0, 0], [-1, 9], [Infinity, 9], [Number.NaN, 9], ['2', 0]]) { const env = await ambilightEnvironment(implementation) const option = { frequency, zIndex: -10 } const attach = env.factory(option) option.opacity = 0.8 const result = attach(env.art) env.art.playing = true env.time(100) assert.equal(result.start(), undefined) assert.equal(env.draws.length, draws) const grid = env.parent.children[0] assert.equal(grid.style.zIndex, 9) assert.equal(grid.children[0].style.opacity, 0.8) assert.equal(grid.children[0].style.filter, 'blur(50px)') const colors = grid.children.map(child => child.style.backgroundColor) assert.equal(result.stop(), undefined) assert.deepEqual(grid.children.map(child => child.style.backgroundColor), colors) assert.deepEqual(Object.keys(result), ['name', 'start', 'stop']) env.art.destroy() } })