OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / webui / test / helpers.js

194 lines · javascript ref:
// A stand-in for an esp324xlight unit: greets "<name> ready", records every
// line it receives, answers `state`, and can drop its clients on demand.
import net from 'bare-net'

export class FakeUnit {
  constructor (name = 'fake') {
    this.name = name
    this.lines = []
    this.conns = new Set()
    this.accepted = 0
    this.values = new Array(12).fill(0)
    this.server = net.createServer((c) => this._onConn(c))
  }

  listen () {
    return new Promise((resolve) => {
      this.server.listen(0, '127.0.0.1', () => {
        this.port = this.server.address().port
        resolve(this)
      })
    })
  }

  _onConn (c) {
    this.accepted++
    this.conns.add(c)
    c.on('close', () => this.conns.delete(c))
    c.on('error', () => {})
    c.write(this.name + ' ready\r\n')
    let buf = ''
    c.on('data', (d) => {
      buf += d.toString()
      let i
      while ((i = buf.indexOf('\n')) !== -1) {
        const line = buf.slice(0, i).trim()
        buf = buf.slice(i + 1)
        if (!line) continue
        this.lines.push(line)
        if (line === 'state') {
          c.write('state 0 1000 1.00 1.00 1.00 ' + this.values.join(' ') + '\r\n')
        }
      }
    })
  }

  dropClients () {
    for (const c of this.conns) c.destroy()
  }

  close () {
    this.dropClients()
    return new Promise((resolve) => this.server.close(resolve))
  }
}

export function waitFor (pred, timeout = 3000) {
  return new Promise((resolve) => {
    const end = Date.now() + timeout
    const tick = () => {
      if (pred()) return resolve(true)
      if (Date.now() > end) return resolve(false)
      setTimeout(tick, 10)
    }
    tick()
  })
}

export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

// A proto-3 unit: referees per-slot grants like esp324xlight's grants.h, so the
// web UI's grant handling is tested against the same rules the firmware applies.
// Signatures are a fixed dummy (signature checks are the bee's job, phase 3).
export class FakeUnitV3 {
  constructor (name = 'fake3', { epoch = 5 } = {}) {
    this.name = name
    this.epoch = epoch
    this.counter = 0
    this.lines = [] // every line received, in order: [connId, line]
    this.values = new Array(12).fill(0)
    this.holds = [null, null, null, null] // { conn, holder, n }
    this.conns = new Map() // id -> socket
    this.nextId = 1
    this.server = net.createServer((c) => this._onConn(c))
  }

  listen () {
    return new Promise((resolve) => {
      this.server.listen(0, '127.0.0.1', () => {
        this.port = this.server.address().port
        resolve(this)
      })
    })
  }

  close () {
    for (const c of this.conns.values()) c.destroy()
    return new Promise((resolve) => this.server.close(resolve))
  }

  received (pred) {
    return this.lines.filter(([, l]) => pred(l)).map(([, l]) => l)
  }

  _broadcast (line) {
    for (const c of this.conns.values()) c.write(line + '\r\n')
  }

  _free (s, why) {
    const h = this.holds[s]
    this.holds[s] = null
    const v = this.values.slice(s * 3, s * 3 + 3)
    this._broadcast(['free', s, this.epoch, h.n, ...v, why, h.holder, 'ab'.repeat(64)].join(' '))
  }

  _setValues (from, vals, skipHeld) {
    let changed = false
    vals.forEach((v, i) => {
      const c = from + i
      if (skipHeld && this.holds[Math.floor(c / 3)]) return
      if (this.values[c] !== v) { this.values[c] = v; changed = true }
    })
    if (changed) this._broadcast('values ' + this.values.join(' '))
  }

  _onConn (c) {
    const id = this.nextId++
    this.conns.set(id, c)
    c.on('error', () => {})
    // bare-net is half-open by default: 'close' only follows an explicit destroy.
    c.on('end', () => c.destroy())
    c.on('close', () => {
      this.conns.delete(id)
      for (let s = 0; s < 4; s++) if (this.holds[s] && this.holds[s].conn === id) this._free(s, 'disconnect')
    })
    c.write(this.name + ' ready\r\n')
    c.write('hello proto=3 unit=' + this.name + ' epoch=' + this.epoch + ' pk=' + 'cd'.repeat(32) + '\r\n')
    let buf = ''
    c.on('data', (d) => {
      buf += d.toString()
      let i
      while ((i = buf.indexOf('\n')) !== -1) {
        const line = buf.slice(0, i).trim()
        buf = buf.slice(i + 1)
        if (line) this._command(id, c, line)
      }
    })
  }

  _command (id, c, line) {
    this.lines.push([id, line])
    const p = line.split(/\s+/)
    const range = (a) => (a === 'all' ? [0, 1, 2, 3] : [Number(a)])
    if (p[0] === 'state') {
      c.write('state 0 1000 1.00 1.00 1.00 ' + this.values.join(' ') + '\r\n')
      this.holds.forEach((h, s) => { if (h) c.write(['held', s, h.holder, this.epoch, h.n].join(' ') + '\r\n') })
      c.write('stats epoch=' + this.epoch + ' dropped=0 pk=' + 'cd'.repeat(32) + '\r\n')
    } else if (p[0] === 'grant') {
      const slots = range(p[1])
      const other = slots.find((s) => this.holds[s] && this.holds[s].conn !== id)
      if (other !== undefined) { c.write('busy ' + other + ' ' + this.holds[other].holder + '\r\n'); return }
      for (const s of slots) {
        if (!this.holds[s]) {
          this.holds[s] = { conn: id, holder: p[2], n: ++this.counter }
          this._broadcast(['held', s, p[2], this.epoch, this.holds[s].n].join(' '))
        }
        c.write(['granted', s, this.epoch, this.holds[s].n].join(' ') + '\r\n')
      }
    } else if (p[0] === 'slot') {
      const [s, e, n, b, g, r] = p.slice(1, 7).map(Number)
      const h = this.holds[s]
      if (!h || h.conn !== id || e !== this.epoch || n !== h.n) { c.write('ERR notheld ' + s + '\r\n'); return }
      this._setValues(s * 3, [b, g, r], false)
    } else if (p[0] === 'release') {
      for (const s of range(p[1])) if (this.holds[s] && this.holds[s].conn === id) this._free(s, 'release')
    } else if (p[0] === 'pwm') {
      this._setValues(0, p.slice(1).map(Number), true)
    } else if (p[0] === 'pwm2') {
      this._setValues(0, p.slice(3).map(Number), true)
    } else if (p[0] === 'stopfade') {
      for (let s = 0; s < 4; s++) if (this.holds[s]) this._free(s, 'blackout')
    }
  }

  // Another controller (e.g. Blender) grabbing a slot on its own socket.
  async externalGrab (slot, holder) {
    const sock = net.createConnection({ port: this.port, host: '127.0.0.1' })
    await new Promise((resolve) => sock.on('connect', resolve))
    sock.on('error', () => {})
    sock.write('grant ' + slot + ' ' + holder + '\n')
    await waitFor(() => this.holds[slot] && this.holds[slot].holder === holder)
    return sock
  }
}