| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "cthuloid-web", |
| 3 | "version": "0.1.0", | |
| 3 | "version": "0.2.0", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "cthuloid-web", |
| 9 | "version": "0.1.0", | |
| 9 | "version": "0.2.0", | |
| 10 | 10 | "dependencies": { |
| 11 | 11 | "bare-events": "^2.9.2", |
| 12 | 12 | "bare-fs": "^4.8.1", |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "cthuloid-web", |
| 3 | "version": "0.1.1", | |
| 3 | "version": "0.2.0", | |
| 4 | 4 | "description": "Web control for cthuloid ESP32 light units (esp324xlight) \u2014 discovers units over mDNS and drives them from a browser on the LAN.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -133,3 +133,10 @@ main { padding: 0 16px 2rem; max-width: 980px; margin: 0 auto; } |
| 133 | 133 | .looks li span { flex: 1; overflow-wrap: anywhere; } |
| 134 | 134 | .muted { color: var(--muted); } |
| 135 | 135 | .small { font-size: .85rem; } |
| 136 | ||
| 137 | /* Shared control (firmware proto 3) */ | |
| 138 | .me { display: flex; align-items: center; gap: .35rem; font-size: .85rem; color: var(--muted); } | |
| 139 | .me input { width: 8.5rem; } | |
| 140 | .slot.held-other .slot-row { opacity: .55; } | |
| 141 | .slot-holder { margin: .15rem 0 0 3.7rem; color: var(--warn); } | |
| 142 | .picker.disabled { opacity: .55; pointer-events: none; } |
| … | … | @@ -5,6 +5,24 @@ import { Picker } from './picker.js' |
| 5 | 5 | |
| 6 | 6 | const $ = (sel, el) => (el || document).querySelector(sel) |
| 7 | 7 | |
| 8 | // ---- who is at this page ------------------------------------------------------ | |
| 9 | // A session id per browser (shared by its tabs: same person) and a display name. | |
| 10 | // Units with shared control (firmware proto 3) show the name to everyone while | |
| 11 | // this browser holds a slot. Storage can be unavailable (private mode): then | |
| 12 | // both just last for this page load. | |
| 13 | ||
| 14 | function stored (key, make) { | |
| 15 | try { | |
| 16 | let v = localStorage.getItem(key) | |
| 17 | if (!v) { v = make(); localStorage.setItem(key, v) } | |
| 18 | return v | |
| 19 | } catch { | |
| 20 | return make() | |
| 21 | } | |
| 22 | } | |
| 23 | const SESSION = stored('cthuloid.session', () => Math.random().toString(36).slice(2, 10)) | |
| 24 | let myName = stored('cthuloid.name', () => 'web-' + SESSION.slice(0, 4)) | |
| 25 | ||
| 8 | 26 | // ---- talking to the server -------------------------------------------------- |
| 9 | 27 | |
| 10 | 28 | async function post (path, body) { |
| … | … | @@ -16,7 +34,12 @@ async function post (path, body) { |
| 16 | 34 | body: JSON.stringify(body || {}) |
| 17 | 35 | }) |
| 18 | 36 | const out = await res.json().catch(() => ({})) |
| 19 | if (!res.ok) throw new Error(out.error || res.statusText) | |
| 37 | if (!res.ok) { | |
| 38 | const err = new Error(out.error || (out.busy ? 'held by ' + out.busy : res.statusText)) | |
| 39 | err.status = res.status | |
| 40 | err.body = out | |
| 41 | throw err | |
| 42 | } | |
| 20 | 43 | return out |
| 21 | 44 | } |
| 22 | 45 | |
| … | … | @@ -24,15 +47,27 @@ async function post (path, body) { |
| 24 | 47 | // newest value is sent next. A drag can't queue a backlog, and a slow reply |
| 25 | 48 | // can never land an older value after a newer one. |
| 26 | 49 | const senders = new Map() |
| 27 | function sendLatest (key, path, body) { | |
| 50 | function sender (key) { | |
| 28 | 51 | let s = senders.get(key) |
| 29 | 52 | if (!s) { |
| 30 | s = { busy: false, next: null } | |
| 53 | s = { busy: false, next: null, after: [], onError: null } | |
| 31 | 54 | senders.set(key, s) |
| 32 | 55 | } |
| 56 | return s | |
| 57 | } | |
| 58 | function sendLatest (key, path, body, onError = null) { | |
| 59 | const s = sender(key) | |
| 33 | 60 | s.next = { path, body } |
| 61 | s.onError = onError | |
| 34 | 62 | if (!s.busy) pump(s) |
| 35 | 63 | } |
| 64 | // Run fn once everything queued on `key` has been sent -- e.g. a release must | |
| 65 | // follow the slot's last value, never overtake it. | |
| 66 | function afterSends (key, fn) { | |
| 67 | const s = sender(key) | |
| 68 | if (!s.busy && !s.next) return fn() | |
| 69 | s.after.push(fn) | |
| 70 | } | |
| 36 | 71 | async function pump (s) { |
| 37 | 72 | s.busy = true |
| 38 | 73 | while (s.next) { |
| … | … | @@ -41,10 +76,13 @@ async function pump (s) { |
| 41 | 76 | try { |
| 42 | 77 | await post(path, body) |
| 43 | 78 | } catch (err) { |
| 44 | console.warn(path, err.message) | |
| 79 | if (s.onError) s.onError(err) | |
| 80 | else console.warn(path, err.message) | |
| 45 | 81 | } |
| 46 | 82 | } |
| 47 | 83 | s.busy = false |
| 84 | const after = s.after.splice(0) | |
| 85 | for (const fn of after) fn() | |
| 48 | 86 | } |
| 49 | 87 | |
| 50 | 88 | function act (fn) { |
| … | … | @@ -65,6 +103,17 @@ function actualCss (values, i, redMax) { |
| 65 | 103 | return 'rgb(' + [r, g, b].map((v) => Math.round(v * 255)).join(',') + ')' |
| 66 | 104 | } |
| 67 | 105 | |
| 106 | // A slot's colour from the unit's live values (BGR, red capped), for mirroring a | |
| 107 | // slot someone else is moving. | |
| 108 | function slotFromValues (v, i, redMax) { | |
| 109 | const red = Math.min(redMax, 45000) || 1 | |
| 110 | const b = v[i * 3] / 65535 | |
| 111 | const g = v[i * 3 + 1] / 65535 | |
| 112 | const r = Math.min(1, v[i * 3 + 2] / red) | |
| 113 | const level = Math.max(r, g, b) | |
| 114 | return level > 0 ? { r: r / level, g: g / level, b: b / level, level } : { r: 1, g: 1, b: 1, level: 0 } | |
| 115 | } | |
| 116 | ||
| 68 | 117 | // Same words the Blender addon uses, so the two read alike. |
| 69 | 118 | function stateWords (st, u) { |
| 70 | 119 | if (!u.enabled) return ['off', 'off'] |
| … | … | @@ -152,18 +201,76 @@ function makeCard (id) { |
| 152 | 201 | $('.slot-n', el2).textContent = 'Slot ' + (i + 1) |
| 153 | 202 | // The slot as the user is editing it. V is the brightness: the picker's |
| 154 | 203 | // value bar and the row's slider are the same control. |
| 155 | const row = { el: el2, hsv: { h: 0, s: 0, v: 1 }, picker: null } | |
| 204 | const row = { | |
| 205 | el: el2, hsv: { h: 0, s: 0, v: 1 }, picker: null, | |
| 206 | claim: 'idle', // proto 3: idle | asking | held | |
| 207 | wantSend: false, pointerDown: false, releaseTimer: null | |
| 208 | } | |
| 156 | 209 | const level = $('.level', el2) |
| 157 | 210 | const panel = $('.slot-picker', el2) |
| 158 | 211 | const swatch = $('.swatch', el2) |
| 159 | const send = () => { | |
| 212 | const key = id + ':' + i | |
| 213 | const shared = () => { const u = unitState(id); return u && u.proto >= 3 } | |
| 214 | ||
| 215 | const sendValue = () => { | |
| 216 | row.wantSend = false | |
| 217 | sendLatest(key, base + '/slot', { index: i, session: SESSION, ...hsvToSlot(row.hsv.h, row.hsv.s, row.hsv.v) }, | |
| 218 | (err) => { | |
| 219 | // The unit let go (idle / 30 s max / reconnect): grab again on the next change. | |
| 220 | if (err.status === 409) row.claim = 'idle' | |
| 221 | }) | |
| 222 | } | |
| 223 | const release = () => { | |
| 224 | clearTimeout(row.releaseTimer) | |
| 225 | if (row.claim !== 'held') return | |
| 226 | row.claim = 'idle' | |
| 227 | afterSends(key, () => post(base + '/release', { index: i, session: SESSION }).catch(() => {})) | |
| 228 | } | |
| 229 | // Take the slot (proto 3), then send. First touch asks once; frames that | |
| 230 | // arrive meanwhile are kept as the latest and sent when it's granted. | |
| 231 | const claim = () => { | |
| 232 | if (row.claim !== 'idle') return | |
| 233 | row.claim = 'asking' | |
| 234 | post(base + '/grab', { index: i, session: SESSION, name: myName }).then(() => { | |
| 235 | row.claim = 'held' | |
| 236 | if (row.wantSend) sendValue() | |
| 237 | if (!row.pointerDown) armRelease() | |
| 238 | }, (err) => { | |
| 239 | row.claim = 'idle' | |
| 240 | refused(row, err.body && err.body.busy ? err.body.busy : err.message) | |
| 241 | }) | |
| 242 | } | |
| 243 | // Let go after a second without changes (keyboard input, or a lifted | |
| 244 | // finger the browser didn't report) -- well inside the unit's 2 s idle. | |
| 245 | const armRelease = () => { | |
| 246 | clearTimeout(row.releaseTimer) | |
| 247 | row.releaseTimer = setTimeout(release, 1000) | |
| 248 | } | |
| 249 | const changed = () => { | |
| 160 | 250 | paintSlot(row) |
| 161 | sendLatest(id + ':' + i, base + '/slot', { index: i, ...hsvToSlot(row.hsv.h, row.hsv.s, row.hsv.v) }) | |
| 251 | if (!shared()) return sendValue() // proto 2: no grants | |
| 252 | if (!row.pointerDown) armRelease() | |
| 253 | if (row.claim === 'held') return sendValue() | |
| 254 | row.wantSend = true | |
| 255 | claim() | |
| 162 | 256 | } |
| 257 | const start = () => { | |
| 258 | row.pointerDown = true | |
| 259 | clearTimeout(row.releaseTimer) | |
| 260 | if (shared()) claim() | |
| 261 | } | |
| 262 | const end = () => { | |
| 263 | row.pointerDown = false | |
| 264 | if (shared()) release() | |
| 265 | } | |
| 266 | ||
| 267 | level.addEventListener('pointerdown', start) | |
| 268 | level.addEventListener('pointerup', end) | |
| 269 | level.addEventListener('pointercancel', end) | |
| 163 | 270 | level.addEventListener('input', () => { |
| 164 | 271 | row.hsv = { ...row.hsv, v: Number(level.value) / 100 } |
| 165 | 272 | if (row.picker) row.picker.set(row.hsv) |
| 166 | send() | |
| 273 | changed() | |
| 167 | 274 | }) |
| 168 | 275 | swatch.onclick = () => { |
| 169 | 276 | const opening = panel.hidden |
| … | … | @@ -173,8 +280,10 @@ function makeCard (id) { |
| 173 | 280 | row.picker = new Picker(panel, { |
| 174 | 281 | onChange: (hsv) => { |
| 175 | 282 | row.hsv = hsv |
| 176 | send() | |
| 177 | } | |
| 283 | changed() | |
| 284 | }, | |
| 285 | onStart: start, | |
| 286 | onEnd: end | |
| 178 | 287 | }) |
| 179 | 288 | } |
| 180 | 289 | row.picker.set(row.hsv) |
| … | … | @@ -228,11 +337,23 @@ function updateCard (card, u, st) { |
| 228 | 337 | const row = card.slots[i] |
| 229 | 338 | const s = u.slots ? u.slots[i] : null |
| 230 | 339 | row.el.hidden = !u.slots |
| 340 | const hold = u.holds ? u.holds[i] : null | |
| 341 | const other = !!hold && hold.session !== SESSION | |
| 342 | const mine = !!hold && hold.session === SESSION | |
| 343 | if (other && row.claim === 'held') row.claim = 'idle' // the unit gave it away | |
| 344 | // Someone else's slot: no input, and the controls mirror the lamp live. | |
| 345 | row.el.classList.toggle('held-other', other) | |
| 346 | $('.level', row.el).disabled = other | |
| 347 | if (row.picker) row.picker.setEnabled(!other) | |
| 348 | const tag = $('.slot-holder', row.el) | |
| 349 | tag.hidden = !hold | |
| 350 | tag.textContent = other ? 'held by ' + hold.holder : (mine ? 'you have this slot' : '') | |
| 231 | 351 | // Skip the echo while the user is moving this slot's controls, or an |
| 232 | 352 | // older value from the server would yank the handle back mid-drag. |
| 233 | const busy = document.activeElement === $('.level', row.el) || (row.picker && row.picker.dragging) | |
| 234 | if (s && !busy) { | |
| 235 | row.hsv = slotToHsv(s, row.hsv.h) | |
| 353 | const busy = row.pointerDown || document.activeElement === $('.level', row.el) || (row.picker && row.picker.dragging) | |
| 354 | const shown = other && actual ? slotFromValues(actual, i, u.redMax) : s | |
| 355 | if (shown && !busy) { | |
| 356 | row.hsv = slotToHsv(shown, row.hsv.h) | |
| 236 | 357 | paintSlot(row) |
| 237 | 358 | if (row.picker) row.picker.set(row.hsv) |
| 238 | 359 | } |
| … | … | @@ -251,6 +372,19 @@ function updateCard (card, u, st) { |
| 251 | 372 | $('.adopt', el).disabled = !u.actual |
| 252 | 373 | } |
| 253 | 374 | |
| 375 | function unitState (id) { | |
| 376 | return state ? state.units.find((u) => u.id === id) : null | |
| 377 | } | |
| 378 | ||
| 379 | // A grab was refused: put the controls back to what the lamp shows and say why. | |
| 380 | function refused (row, who) { | |
| 381 | row.wantSend = false | |
| 382 | const tag = $('.slot-holder', row.el) | |
| 383 | tag.hidden = false | |
| 384 | tag.textContent = 'held by ' + who | |
| 385 | if (state) render(state) | |
| 386 | } | |
| 387 | ||
| 254 | 388 | // One picker open at a time, page-wide. |
| 255 | 389 | let openRow = null |
| 256 | 390 | function closePicker () { |
| … | … | @@ -269,6 +403,13 @@ function paintSlot (row) { |
| 269 | 403 | |
| 270 | 404 | // ---- wiring ------------------------------------------------------------------- |
| 271 | 405 | |
| 406 | $('#my-name').value = myName | |
| 407 | $('#my-name').addEventListener('change', (ev) => { | |
| 408 | myName = ev.target.value.trim() || myName | |
| 409 | ev.target.value = myName | |
| 410 | try { localStorage.setItem('cthuloid.name', myName) } catch {} | |
| 411 | }) | |
| 412 | ||
| 272 | 413 | $('#connect').onclick = act(() => post('/api/connect', { on: !(state && state.connected) })) |
| 273 | 414 | $('#blackout-all').onclick = act(() => post('/api/blackout')) |
| 274 | 415 | $('#save-look').onsubmit = act(async () => { |
| … | … | @@ -10,6 +10,7 @@ |
| 10 | 10 | <header class="bar"> |
| 11 | 11 | <h1>Cthuloid Lights</h1> |
| 12 | 12 | <div class="bar-actions"> |
| 13 | <label class="me" title="Shown to others while you hold a slot">You <input id="my-name" type="text" maxlength="32" spellcheck="false"></label> | |
| 13 | 14 | <button id="connect" class="connect" type="button">CONNECT</button> |
| 14 | 15 | <button id="blackout-all" class="danger" type="button" title="Stop fades and drive every unit to zero, connected or not">Blackout all</button> |
| 15 | 16 | </div> |
| … | … | @@ -82,6 +83,7 @@ |
| 82 | 83 | <span class="pct small"></span> |
| 83 | 84 | <span class="actual" title="What the unit is actually showing"></span> |
| 84 | 85 | </div> |
| 86 | <div class="slot-holder small" hidden></div> | |
| 85 | 87 | <div class="slot-picker" hidden></div> |
| 86 | 88 | </div> |
| 87 | 89 | </template> |
| … | … | @@ -2,8 +2,12 @@ |
| 2 | 2 | // and RGB / HSV / Hex fields underneath. Pointer events, so mouse and touch |
| 3 | 3 | // both drag. Values are 0..1 throughout, as in Blender. |
| 4 | 4 | // |
| 5 | // const p = new Picker(el, { onChange: ({ h, s, v }) => ... }) | |
| 5 | // const p = new Picker(el, { onChange: ({ h, s, v }) => ..., onStart, onEnd }) | |
| 6 | 6 | // p.set({ h, s, v }) // from the server; ignored while the user drags |
| 7 | // p.setEnabled(false) // someone else holds the slot: no input | |
| 8 | // | |
| 9 | // onStart / onEnd bracket a wheel or value-bar drag, so the page can take the | |
| 10 | // slot's grant for exactly as long as the pointer is down. | |
| 7 | 11 | |
| 8 | 12 | import { hsvToRgb, rgbToHsv, toHex, fromHex } from './color.js' |
| 9 | 13 | |
| … | … | @@ -41,8 +45,11 @@ function wheelBitmap (size) { |
| 41 | 45 | } |
| 42 | 46 | |
| 43 | 47 | export class Picker { |
| 44 | constructor (root, { onChange }) { | |
| 48 | constructor (root, { onChange, onStart = () => {}, onEnd = () => {} }) { | |
| 45 | 49 | this.onChange = onChange |
| 50 | this.onStart = onStart | |
| 51 | this.onEnd = onEnd | |
| 52 | this.enabled = true | |
| 46 | 53 | this.h = 0 |
| 47 | 54 | this.s = 0 |
| 48 | 55 | this.v = 1 |
| … | … | @@ -82,6 +89,12 @@ export class Picker { |
| 82 | 89 | this._draw() |
| 83 | 90 | } |
| 84 | 91 | |
| 92 | setEnabled (on) { | |
| 93 | this.enabled = on | |
| 94 | this.root.classList.toggle('disabled', !on) | |
| 95 | for (const el of this.root.querySelectorAll('input, button')) el.disabled = !on | |
| 96 | } | |
| 97 | ||
| 85 | 98 | set ({ h, s, v }) { |
| 86 | 99 | if (this.dragging) return |
| 87 | 100 | this.h = h |
| … | … | @@ -95,8 +108,10 @@ export class Picker { |
| 95 | 108 | _drag (el, apply) { |
| 96 | 109 | el.addEventListener('pointerdown', (e) => { |
| 97 | 110 | e.preventDefault() |
| 111 | if (!this.enabled) return | |
| 98 | 112 | el.setPointerCapture(e.pointerId) |
| 99 | 113 | this.dragging = true |
| 114 | this.onStart() | |
| 100 | 115 | apply(e) |
| 101 | 116 | }) |
| 102 | 117 | el.addEventListener('pointermove', (e) => { |
| … | … | @@ -104,7 +119,10 @@ export class Picker { |
| 104 | 119 | }) |
| 105 | 120 | const end = (e) => { |
| 106 | 121 | if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId) |
| 107 | this.dragging = false | |
| 122 | if (this.dragging) { | |
| 123 | this.dragging = false | |
| 124 | this.onEnd() | |
| 125 | } | |
| 108 | 126 | } |
| 109 | 127 | el.addEventListener('pointerup', end) |
| 110 | 128 | el.addEventListener('pointercancel', end) |
| … | … | @@ -0,0 +1,112 @@ |
| 1 | // Manual browser test (Node + Chromium over CDP), not part of `npm test`: | |
| 2 | // needs a fake proto-3 unit on :1339 and this server on :4052 with it included | |
| 3 | // and connected. Usage: node test/browser-shared-control.mjs <dir-with-fake3-lines.txt> | |
| 4 | // Two real Chromium browsers (separate profiles => separate session ids) drive | |
| 5 | // the page with real mouse events, against a local server + fake proto-3 unit. | |
| 6 | import { spawn } from 'node:child_process' | |
| 7 | import { writeFileSync, mkdirSync, readFileSync } from 'node:fs' | |
| 8 | ||
| 9 | const OUT = process.argv[2] | |
| 10 | const URL = 'http://127.0.0.1:4052/' | |
| 11 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) | |
| 12 | const results = [] | |
| 13 | const check = (name, ok, detail = '') => { results.push(ok); console.log((ok ? 'PASS ' : 'FAIL ') + name + (ok ? '' : ' ' + detail)) } | |
| 14 | ||
| 15 | async function browser (port, label) { | |
| 16 | const prof = process.env.HOME + '/.cache/cthuloid-shots/prof-' + label + '-' + Date.now() | |
| 17 | mkdirSync(prof, { recursive: true }) | |
| 18 | const proc = spawn('chromium', ['--headless=new', '--disable-gpu', '--remote-debugging-port=' + port, | |
| 19 | '--user-data-dir=' + prof, '--window-size=1100,1000', 'about:blank'], { stdio: 'ignore' }) | |
| 20 | let target | |
| 21 | for (let i = 0; i < 50 && !target; i++) { | |
| 22 | await sleep(200) | |
| 23 | try { target = (await (await fetch('http://127.0.0.1:' + port + '/json')).json()).find((t) => t.type === 'page') } catch {} | |
| 24 | } | |
| 25 | const ws = new WebSocket(target.webSocketDebuggerUrl) | |
| 26 | await new Promise((r) => ws.addEventListener('open', r)) | |
| 27 | let id = 0 | |
| 28 | const pending = new Map() | |
| 29 | ws.addEventListener('message', (ev) => { | |
| 30 | const m = JSON.parse(ev.data) | |
| 31 | if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) } | |
| 32 | }) | |
| 33 | const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) }) | |
| 34 | const b = { | |
| 35 | label, | |
| 36 | send, | |
| 37 | eval: async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })).result.result.value, | |
| 38 | mouse: (type, x, y) => send('Input.dispatchMouseEvent', { type, x, y, button: 'left', buttons: type === 'mouseReleased' ? 0 : 1, clickCount: 1, pointerType: 'mouse' }), | |
| 39 | shot: async (name) => { const r = await send('Page.captureScreenshot', { format: 'png' }); writeFileSync(OUT + '/' + name, Buffer.from(r.result.data, 'base64')) }, | |
| 40 | box: async (sel) => b.eval(`(() => { const e = [...document.querySelectorAll(${JSON.stringify(sel)})].find(x => x.offsetParent); if (!e) return null; const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height } })()`), | |
| 41 | close: () => { ws.close(); proc.kill() } | |
| 42 | } | |
| 43 | await send('Page.enable') | |
| 44 | await send('Runtime.enable') | |
| 45 | await send('Page.navigate', { url: URL }) | |
| 46 | await sleep(2000) | |
| 47 | return b | |
| 48 | } | |
| 49 | ||
| 50 | const card = '.unit:not(.off)' | |
| 51 | const slot1 = `[...document.querySelectorAll('${card} .slot')][0]` | |
| 52 | ||
| 53 | const alice = await browser(9341, 'alice') | |
| 54 | const bob = await browser(9342, 'bob') | |
| 55 | for (const [b, name] of [[alice, 'alice'], [bob, 'bob']]) { | |
| 56 | await b.eval(`(() => { const i = document.querySelector('#my-name'); i.value = '${name}'; i.dispatchEvent(new Event('change')); return 1 })()`) | |
| 57 | } | |
| 58 | ||
| 59 | // Alice opens slot 1's picker and starts dragging the wheel -- and keeps holding. | |
| 60 | const sw = await alice.box(`${card} .swatch`) | |
| 61 | await alice.mouse('mousePressed', sw.x + sw.w / 2, sw.y + sw.h / 2) | |
| 62 | await alice.mouse('mouseReleased', sw.x + sw.w / 2, sw.y + sw.h / 2) | |
| 63 | await sleep(300) | |
| 64 | const wheel = await alice.box('.pk-wheel') | |
| 65 | const cx = wheel.x + wheel.w / 2 | |
| 66 | const cy = wheel.y + wheel.h / 2 | |
| 67 | await alice.mouse('mousePressed', cx, cy) | |
| 68 | for (let k = 1; k <= 8; k++) { await alice.mouse('mouseMoved', cx, cy - (wheel.h / 2 - 2) * k / 8); await sleep(40) } | |
| 69 | await sleep(700) // still pressed | |
| 70 | ||
| 71 | const bobView = await bob.eval(`(() => { const r = ${slot1}; return { held: r.classList.contains('held-other'), tag: r.querySelector('.slot-holder').textContent, disabled: r.querySelector('.level').disabled } })()`) | |
| 72 | check("bob sees slot 1 held by alice while she drags", bobView.held && bobView.tag === 'held by alice' && bobView.disabled, JSON.stringify(bobView)) | |
| 73 | const aliceView = await alice.eval(`(() => { const r = ${slot1}; return { held: r.classList.contains('held-other'), tag: r.querySelector('.slot-holder').textContent } })()`) | |
| 74 | check('alice sees it as hers', !aliceView.held && aliceView.tag === 'you have this slot', JSON.stringify(aliceView)) | |
| 75 | await bob.shot('p2b-bob-greyed.png') | |
| 76 | ||
| 77 | // Bob tries to drag the slot's brightness anyway: the control is disabled. | |
| 78 | const lv = await bob.box(`${card} .level`) | |
| 79 | await bob.mouse('mousePressed', lv.x + 5, lv.y + lv.h / 2) | |
| 80 | await bob.mouse('mouseMoved', lv.x + lv.w - 5, lv.y + lv.h / 2) | |
| 81 | await bob.mouse('mouseReleased', lv.x + lv.w - 5, lv.y + lv.h / 2) | |
| 82 | await sleep(400) | |
| 83 | ||
| 84 | // Alice lets go. | |
| 85 | await alice.mouse('mouseReleased', cx, cy - wheel.h / 2 + 2) | |
| 86 | await sleep(1000) | |
| 87 | const bobAfter = await bob.eval(`(() => { const r = ${slot1}; return { held: r.classList.contains('held-other'), disabled: r.querySelector('.level').disabled } })()`) | |
| 88 | check('after alice lets go, bob can use slot 1', !bobAfter.held && !bobAfter.disabled, JSON.stringify(bobAfter)) | |
| 89 | ||
| 90 | // Now bob drags slot 1's brightness: alice sees it held by bob. | |
| 91 | await bob.mouse('mousePressed', lv.x + lv.w - 5, lv.y + lv.h / 2) | |
| 92 | for (let k = 1; k <= 6; k++) { await bob.mouse('mouseMoved', lv.x + lv.w - 5 - (lv.w - 10) * k / 12, lv.y + lv.h / 2); await sleep(40) } | |
| 93 | await sleep(600) | |
| 94 | const aliceSees = await alice.eval(`(() => { const r = ${slot1}; return { held: r.classList.contains('held-other'), tag: r.querySelector('.slot-holder').textContent } })()`) | |
| 95 | check('alice sees slot 1 held by bob while he drags', aliceSees.held && aliceSees.tag === 'held by bob', JSON.stringify(aliceSees)) | |
| 96 | await bob.mouse('mouseReleased', lv.x + lv.w / 2, lv.y + lv.h / 2) | |
| 97 | await sleep(1200) | |
| 98 | ||
| 99 | const lines = readFileSync(OUT + '/fake3-lines.txt', 'utf8').trim().split('\n').map((l) => l.split(' ').slice(1).join(' ')) | |
| 100 | const order = lines.filter((l) => /^(grant|release)/.test(l)) | |
| 101 | check('unit saw: alice grant, release, bob grant, release', JSON.stringify(order) === JSON.stringify(['grant 0 alice', 'release 0', 'grant 0 bob', 'release 0']), JSON.stringify(order)) | |
| 102 | const errs = lines.filter((l) => l.startsWith('ERR')) | |
| 103 | check('no refused frames', errs.length === 0, JSON.stringify(errs)) | |
| 104 | const frames = lines.filter((l) => l.startsWith('slot 0')) | |
| 105 | check('drag frames reached the unit', frames.length >= 2, String(frames.length)) | |
| 106 | const ownerAfter = await alice.eval(`(() => { const r = ${slot1}; return r.classList.contains('held-other') || r.querySelector('.slot-holder').hidden === false })()`) | |
| 107 | check('slot 1 free again everywhere', ownerAfter === false) | |
| 108 | ||
| 109 | alice.close() | |
| 110 | bob.close() | |
| 111 | console.log(results.every(Boolean) ? 'ALL PASS' : 'FAILURES') | |
| 112 | process.exit(results.every(Boolean) ? 0 : 1) |