'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const { EventEmitter } = require('node:events'); const { Controller, sharpCoverUrl } = require('../src/controller'); const { PlaytubeError } = require('../src/playtubeClient'); const { PREFIX } = require('../src/actions'); const silentLogger = { info() {}, warn() {}, error() {} }; const tick = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms)); const STATE = { activeTab: 'music', visibleTab: 'music', tabs: { music: { playing: true, title: 'Song', volume: 40, muted: false, likeStatus: 'none', repeatMode: 'NONE', hasVideo: true, thumbnail: 'https://example.test/x=w60-h60-l90' }, youtube: { playing: false, title: '', volume: 100, muted: false }, }, }; class FakeHost extends EventEmitter { constructor() { super(); this.calls = []; for (const name of ['setTitle', 'setImage', 'setState', 'showAlert', 'showOk', 'sendToPropertyInspector']) { this[name] = (...args) => this.calls.push([name, ...args]); } } named(name) { return this.calls.filter((call) => call[0] === name); } } class FakeClient extends EventEmitter { constructor() { super(); this.connected = true; this.requests = []; this.state = STATE; this.failWith = null; this.playlists = [{ id: 'LM', title: 'Liked' }]; } start() {} stop() {} async request(cmd, fields) { if (this.failWith) throw this.failWith; this.requests.push({ cmd, ...fields }); return { ok: true }; } async status() { return this.state; } async listPlaylists() { return this.playlists; } } function setup() { const host = new FakeHost(); const client = new FakeClient(); const covers = []; const controller = new Controller({ host, client, logger: silentLogger, pollIntervalMs: 100000, fetchImage: async (url) => { covers.push(url); return `data:image/jpeg;base64,${Buffer.from(url).toString('base64')}`; }, }); return { host, client, controller, covers }; } const appear = (controller, id, context = `ctx-${id}`, settings = {}) => controller.handleEvent({ event: 'willAppear', action: `${PREFIX}${id}`, context, payload: { settings }, }); test('sharpCoverUrl hebt die Aufloesung von Music-Covern auf Tastengroesse', () => { assert.equal(sharpCoverUrl('https://x/a=w60-h60-l90-rj'), 'https://x/a=w144-h144-l90-rj'); assert.equal(sharpCoverUrl('https://i.ytimg.com/vi/abc/hq.jpg'), 'https://i.ytimg.com/vi/abc/hq.jpg'); }); test('Taste erscheint offline: Offline-Titel', () => { const { host, client, controller } = setup(); client.connected = false; appear(controller, 'playpause', 'k1', { showTitle: true }); assert.match(host.named('setTitle')[0][2], /offline/); }); test('Lauter: sofort beim Druecken, Wiederholung beim Halten, Stopp beim Loslassen', async () => { const { client, controller } = setup(); appear(controller, 'volumeup', 'v1', { step: 3 }); controller.handleEvent({ event: 'keyDown', context: 'v1' }); await tick(); assert.equal(client.requests.length, 1); assert.deepEqual(client.requests[0], { cmd: 'volume_change', target: 'auto', value: 3 }); assert.ok(controller.repeaters.has('v1')); controller.handleEvent({ event: 'keyUp', context: 'v1' }); assert.equal(controller.repeaters.size, 0); await tick(); assert.equal(client.requests.length, 1); }); test('normale Aktionen laufen beim Loslassen, nicht beim Druecken', async () => { const { client, controller } = setup(); appear(controller, 'next', 'n1'); controller.handleEvent({ event: 'keyDown', context: 'n1' }); await tick(); assert.equal(client.requests.length, 0); controller.handleEvent({ event: 'keyUp', context: 'n1' }); await tick(); assert.deepEqual(client.requests, [{ cmd: 'next', target: 'auto' }]); }); test('Fehler von Playtube loesen einen Alarm auf der Taste aus', async () => { const { host, client, controller } = setup(); client.failWith = new PlaytubeError('not_connected', 'offline'); appear(controller, 'next', 'n1'); controller.handleEvent({ event: 'keyUp', context: 'n1' }); await tick(); assert.equal(host.named('showAlert').length, 1); }); test('Playlist-Taste ohne Auswahl zeigt Alarm und sendet nichts', async () => { const { host, client, controller } = setup(); appear(controller, 'playlist', 'p1'); controller.handleEvent({ event: 'keyUp', context: 'p1' }); await tick(); assert.equal(client.requests.length, 0); assert.equal(host.named('showAlert').length, 1); }); test('Playlist-Taste startet die gewaehlte Playlist und zeigt ihren Namen', async () => { const { host, client, controller } = setup(); appear(controller, 'playlist', 'p1', { playlistId: 'PLabc', playlistTitle: 'Mein Mix' }); assert.equal(host.named('setTitle').at(-1)[2], 'Mein Mix'); controller.handleEvent({ event: 'keyUp', context: 'p1' }); await tick(); assert.deepEqual(client.requests, [{ cmd: 'play_playlist', playlist: 'PLabc' }]); }); test('Status aktualisiert Zustand und Lautstaerke-Anzeige, nur bei Aenderung', async () => { const { host, controller } = setup(); appear(controller, 'playpause', 'pp'); appear(controller, 'volumeup', 'vu'); await controller.poll(); assert.deepEqual(host.named('setState').filter((c) => c[1] === 'pp').at(-1), ['setState', 'pp', 1]); assert.equal(host.named('setTitle').filter((c) => c[1] === 'vu').at(-1)[2], '40%'); const before = host.calls.length; await controller.poll(); assert.equal(host.calls.length, before, 'unveraenderter Status sendet nichts erneut'); }); test('Cover wird in hoher Aufloesung geladen und als Bild gesetzt', async () => { const { host, controller, covers } = setup(); appear(controller, 'nowplaying', 'np'); await controller.poll(); await tick(20); assert.deepEqual(covers, ['https://example.test/x=w144-h144-l90']); const image = host.named('setImage').at(-1); assert.equal(image[1], 'np'); assert.match(image[2], /^data:image\/jpeg;base64,/); }); test('Verbindungsverlust stellt Offline-Anzeige her', async () => { const { host, client, controller } = setup(); controller.start(); appear(controller, 'volumeup', 'vu'); await controller.poll(); assert.equal(host.named('setTitle').filter((c) => c[1] === 'vu').at(-1)[2], '40%'); client.connected = false; client.emit('disconnected'); assert.equal(host.named('setTitle').filter((c) => c[1] === 'vu').at(-1)[2], ''); controller.stop(); }); test('Property Inspector bekommt die Playlists', async () => { const { host, controller } = setup(); controller.handleEvent({ event: 'sendToPlugin', action: `${PREFIX}playlist`, context: 'p1', payload: { command: 'requestPlaylists' } }); await tick(); const [, action, context, payload] = host.named('sendToPropertyInspector')[0]; assert.equal(action, `${PREFIX}playlist`); assert.equal(context, 'p1'); assert.deepEqual(payload, { command: 'playlists', ok: true, playlists: [{ id: 'LM', title: 'Liked' }] }); }); test('Property Inspector: offline und Fehler werden gemeldet', async () => { const { host, client, controller } = setup(); client.connected = false; controller.handleEvent({ event: 'sendToPlugin', action: 'a', context: 'c', payload: { command: 'requestPlaylists' } }); await tick(); assert.equal(host.named('sendToPropertyInspector')[0][3].error, 'offline'); client.connected = true; client.listPlaylists = async () => { throw new PlaytubeError('remote', 'unbekannter Befehl'); }; controller.handleEvent({ event: 'sendToPlugin', action: 'a', context: 'c', payload: { command: 'requestPlaylists' } }); await tick(); const payload = host.named('sendToPropertyInspector')[1][3]; assert.equal(payload.ok, false); assert.equal(payload.error, 'remote'); }); test('willDisappear raeumt Taste und Wiederholung auf', () => { const { controller } = setup(); appear(controller, 'volumeup', 'v1'); controller.handleEvent({ event: 'keyDown', context: 'v1' }); controller.handleEvent({ event: 'willDisappear', context: 'v1' }); assert.equal(controller.keys.size, 0); assert.equal(controller.repeaters.size, 0); });