ituoke/frontend/tests/device-connection.test.mjs

148 lines
7.3 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
import { parse } from '@vue/compiler-sfc'
import * as Vue from 'vue'
import ts from 'typescript'
// Run the real root controller with an in-memory Wails bridge, so manual
// device detection exercises selection, preview and wireless connection together.
const { descriptor } = parse(readFileSync(new URL('../src/App.vue', import.meta.url), 'utf8'))
const source = descriptor.scriptSetup.content + '\nexport { devices, activeId, live, refreshDevices, selectDevice, stopStream, onConnect, onUSBConnect, onDisconnect };'
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
const clone = value => JSON.parse(JSON.stringify(value))
const settle = async () => { for (let i = 0; i < 20; i++) await Promise.resolve() }
const usb = (state = 'device', id = 'usb-phone') => ({ id, model: 'USB phone', mode: 'usb', online: state === 'device', state, width: 1080, height: 2400 })
const lan = (id = '192.0.2.1:5555') => ({ ...usb(), id, model: 'Wireless phone', mode: 'lan' })
function harness(initial = []) {
const calls = [], logs = [], timers = new Map(), cleanup = [], mounted = []
let available = initial, selected = '', timerID = 0, beforeList
let prefs = { theme: 'system', lastTab: 'workspace', splitRatio: .55, screenOff: false }
const api = {
async ListDevices() { calls.push(['list']); if (beforeList) await beforeList(); return clone(available) },
async SelectDevice(id) { calls.push(['select', id]); selected = id; available = available.map(d => d.id === id && d.state === 'disconnected' ? usb('device', id) : d) },
async StartPreviewStream(id) { calls.push(['preview', id]); return { ok: true, message: 'http://localhost:1234/mjpeg' } },
async StopPreviewStream() { calls.push(['stop']) },
async DisconnectDevice(id) { calls.push(['disconnect', id]); available = available.flatMap(d => d.id !== id ? [d] : d.mode === 'usb' ? [{ ...d, online: false, state: 'disconnected' }] : []); return { ok: true } },
async ConnectWireless({ hostPort }) { calls.push(['connect', hostPort]); if (!available.some(d => d.id === hostPort)) available.push(lan(hostPort)); selected = hostPort; return { ok: true, message: 'connected' } },
async GetActiveDevice() { return clone(available.find(d => d.id === selected) || {}) },
}
const module = { exports: {} }
runInNewContext(compiled, {
module, exports: module.exports,
require(name) {
if (name === 'vue') return { ...Vue, onMounted: fn => mounted.push(fn), onUnmounted: fn => cleanup.push(fn) }
if (name.endsWith('/useLogs')) return { useLogs: () => ({ lines: Vue.ref([]), log: line => logs.push(line), load: async () => {} }) }
if (name.endsWith('/useConversations')) return { useConversations: () => ({ messages: Vue.ref([]), sending: Vue.ref(false), load: async () => {} }) }
if (name.endsWith('/prefs')) return { loadPrefs: () => prefs, savePrefs: patch => (prefs = { ...prefs, ...patch }) }
if (name.endsWith('/touch')) return { TouchQueue: class { push() {} } }
if (name.endsWith('/device')) return { Service: api }
if (name.endsWith('/agent')) return { Service: { ProtocolVersion: async () => 'test', Status: async () => ({}) } }
return {}
},
window: { matchMedia: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }), addEventListener() {}, removeEventListener() {} },
document: { documentElement: { dataset: {} } },
crypto: globalThis.crypto,
setTimeout: fn => { const id = ++timerID; timers.set(id, fn); return id },
clearTimeout: id => timers.delete(id),
})
return {
app: module.exports, calls, logs, timers,
devices: value => { available = value },
beforeList: fn => { beforeList = fn },
async mount() { for (const fn of mounted) await fn() },
async dispose() { for (const fn of cleanup) fn(); await settle() },
}
}
test('manual refresh detects USB authorization and unplugging', async () => {
const h = harness([usb('unauthorized')])
await h.app.refreshDevices()
assert.equal(h.app.activeId.value, 'usb-phone')
assert.equal(h.app.live.value, false)
h.devices([usb()])
await h.app.refreshDevices()
assert.equal(h.app.live.value, true)
assert.deepEqual(h.calls.filter(c => c[0] === 'preview'), [['preview', 'usb-phone']])
await h.app.refreshDevices()
assert.equal(h.calls.filter(c => c[0] === 'preview').length, 1)
h.devices([])
await h.app.refreshDevices()
assert.equal(h.app.live.value, false)
assert.equal(h.app.activeId.value, '')
assert.equal(h.calls.some(c => c[0] === 'connect' || c[0] === 'disconnect'), false)
await h.dispose()
})
test('a deliberate preview stop remains stopped on subsequent USB refreshes', async () => {
const h = harness([usb()])
await h.app.refreshDevices()
await h.app.stopStream()
await h.app.refreshDevices()
assert.equal(h.app.live.value, false)
assert.equal(h.calls.filter(c => c[0] === 'preview').length, 1)
await h.app.onDisconnect('usb-phone')
assert.equal(h.calls.some(c => c[0] === 'disconnect'), true)
assert.equal(h.app.live.value, false)
assert.equal(h.app.devices.value[0].state, 'disconnected')
await h.app.refreshDevices()
assert.equal(h.app.live.value, false)
await h.app.onUSBConnect('usb-phone')
assert.equal(h.app.live.value, true)
await h.dispose()
})
test('manual refresh preserves an online wireless selection; explicit USB choice switches preview', async () => {
const h = harness([lan()])
await h.app.refreshDevices()
h.devices([lan(), usb()])
await h.app.refreshDevices()
assert.equal(h.app.activeId.value, '192.0.2.1:5555')
assert.equal(h.calls.some(c => c[0] === 'preview'), false)
await h.app.onUSBConnect('usb-phone')
assert.equal(h.app.activeId.value, 'usb-phone')
assert.deepEqual(h.calls.filter(c => c[0] === 'preview'), [['preview', 'usb-phone']])
assert.equal(h.calls.some(c => c[0] === 'connect'), false)
await h.dispose()
})
test('wireless connect previews its requested device even when USB is already attached', async () => {
const h = harness([usb()])
await h.app.onConnect('192.0.2.8:5555')
assert.equal(h.app.activeId.value, '192.0.2.8:5555')
assert.ok(h.calls.filter(c => c[0] === 'preview').every(c => c[1] === '192.0.2.8:5555'))
assert.equal(h.app.live.value, true)
await h.dispose()
})
test('overlapping manual refreshes share the same device scan', async () => {
const h = harness([usb('unauthorized')])
let release
h.beforeList(() => new Promise(resolve => { release = resolve }))
const first = h.app.refreshDevices()
const second = h.app.refreshDevices()
assert.equal(first, second)
assert.equal(h.calls.filter(c => c[0] === 'list').length, 1)
h.beforeList(undefined)
release()
await first
assert.equal(h.timers.size, 0)
await h.dispose()
})
test('startup and manual refresh do not register automatic device scans', async () => {
const h = harness([usb('unauthorized')])
await h.mount()
assert.equal(h.calls.filter(c => c[0] === 'list').length, 1)
assert.equal(h.timers.size, 0)
await settle()
assert.equal(h.calls.filter(c => c[0] === 'list').length, 1)
await h.app.refreshDevices()
assert.equal(h.calls.filter(c => c[0] === 'list').length, 2)
assert.equal(h.timers.size, 0)
await h.dispose()
})