diff --git a/README.md b/README.md index 06ff30a..b87991b 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ sont conservés en changeant de type. | Afficheur | Affiche en direct une valeur de SimHub ou de MSFS (vitesse, rapport, temps au tour…) ; un appui peut déclencher une action | | Bouton rotatif | Tourner (glisser le doigt) pour « + » / « − », appuyer pour valider ; peut afficher une valeur du simulateur | | Curseur | Glisser pour régler une position (gaz, volets…) ou envoyer des crans « + » / « − » | +| Interrupteur à N positions | Interrupteur à 3 positions, sélecteur à 8 positions… (2 à 12) : une action par position, un appui passe à la suivante (aller-retour ou en boucle), position lue dans le simulateur, visuel par position | | Bascule (2 états) | Alterne entre deux états (ex. train rentré / sorti) : titre, icône, couleur et action propres à chaque état |
diff --git a/package-lock.json b/package-lock.json index 9041759..3c6c253 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "streamsim", - "version": "0.12.10", + "version": "0.12.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "streamsim", - "version": "0.12.10", + "version": "0.12.11", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9", diff --git a/package.json b/package.json index 625474b..f89c87d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "streamsim", "productName": "StreamSim", - "version": "0.12.10", + "version": "0.12.11", "description": "Clone logiciel de Stream Deck : serveur PC, application de configuration et client Android.", "author": "StreamSim", "license": "MIT", diff --git a/public/docs.html b/public/docs.html index 97c42f2..5283979 100644 --- a/public/docs.html +++ b/public/docs.html @@ -172,6 +172,7 @@
Pour un interrupteur à trois positions (haut / milieu / bas), un sélecteur à huit positions ou tout autre commutateur de 2 à 12 positions. Chaque position a sa propre action (raccourci, commande MSFS, variable L:, SimHub…) ; à chaque appui, la touche envoie l'action de la position suivante.
L: de l'interrupteur et cliquez sur Remplir les positions : chaque position fixe la variable à 0, 1, 2… et la touche lit sa position dans cette même variable. Vous pouvez ensuite modifier chaque position à la main.
Droite, « Tourner − » → Gauche (deux raccourcis clavier, mais n'importe quel type d'action convient).Un bouton rotatif se manipule en glissant le doigt (ou la molette de la souris sur PC) :
diff --git a/public/js/app.js b/public/js/app.js index e33be27..3ac0a4a 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -7,6 +7,7 @@ import { } from './catalog.js'; import { computeCells, placementError, findFreeSlot, keySpan, stateKey } from '/shared/layout.js'; import { SIMHUB_PROPERTIES } from '/shared/simhub.js'; +import { switchCount } from '/shared/controls.js'; import { AVIATION_ICON_GROUPS, aviationIcon, SWITCH_FACE_GROUPS, switchFaces } from '/shared/icons.js'; import { library, refreshIcons, uploadIconFile, openIconLibrary, exportIcons, pickAndImportIcons } from './icons.js'; import { MSFS_EVENTS, MSFS_EVENT_LABELS, MSFS_SIMVARS, MSFS_NUMERIC_SIMVARS, MSFS_UNITS, FBW_EVENTS, FBW_PRESETS, isLocalVar } from '/shared/msfs.js'; @@ -772,6 +773,8 @@ function actionFields(getAction, tag) { return [multiEditor(getAction, tag)]; case 'toggle': return [toggleEditor(getAction, tag)]; + case 'switch': + return [switchEditor(getAction, tag)]; case 'msfs': return msfsFields(getAction, tag); case 'simhub': @@ -1710,6 +1713,110 @@ function sizeField(i) { const INNER_TYPES = ['msfs', 'simhub', 'hotkey', 'text', 'media', 'launch', 'url', 'command', 'multi']; // Éditeur d'une touche à bascule : une action par état (ou la même pour les deux). +// Interrupteur à N positions : une action par position, lecture facultative de la position réelle. +function switchEditor(getAction, tag) { + const a = getAction(); + const n = switchCount(a); + const SRC = ['simvar', 'input', 'simhub']; + // La liste des positions suit le nombre choisi (les actions déjà saisies sont conservées). + if (!Array.isArray(a.positions)) a.positions = []; + while (a.positions.length < n) a.positions.push(ACTION_TYPES.hotkey.create()); + a.positions.length = n; + const d = a.display ?? {}; + const synced = SRC.some((k) => k in d); + const matching = SWITCH_FACE_GROUPS.filter((g) => g.positions.length === n); + const mode = a.mode === 'cycle' ? 'cycle' : 'bounce'; + const setCount = (v) => + commit(() => { + const c = switchCount({ count: v }); + const act = getAction(); + act.count = c; + while (act.positions.length < c) act.positions.push(ACTION_TYPES.hotkey.create()); + act.positions.length = c; + if (Array.isArray(act.display?.images)) act.display.images.length = Math.min(act.display.images.length, c); + if (Array.isArray(act.display?.values) && act.display.values.length !== c) delete act.display.values; + }); + const posKey = h('input', { type: 'text', placeholder: 'ex. AZP_RAF_SELECTOR (variable L: MobiFlight)' }); + const fillFromVar = () => { + const raw = posKey.value.trim(); + if (!raw) return toast('Saisissez le nom de la variable.', 'err'); + const name = /^[A-Za-z]:/.test(raw) ? raw : `L:${raw}`; + commit(() => { + const act = getAction(); + act.positions = Array.from({ length: switchCount(act) }, (_, i) => ({ type: 'msfs', kind: 'var', var: name, unit: 'number', op: 'set', value: i })); + act.display = { ...(act.display ?? {}), simvar: name, unit: 'number' }; + delete act.display.input; + delete act.display.simhub; + delete act.display.values; + }); + }; + return h( + 'div', + { class: 'field' }, + h('div', { class: 'note info' }, icon('info'), + h('span', {}, 'Chaque appui envoie l’action de la position suivante : en aller-retour (haut → milieu → bas → milieu…) ou en boucle (1 → 2 → 3 → 1…). Si vous liez une valeur du simulateur, la touche suit la position réelle de l’interrupteur, même s’il est manœuvré dans le cockpit.')), + h('div', { class: 'row' }, + h('label', { class: 'field' }, h('span', {}, 'Nombre de positions (2 à 12)'), + h('input', { type: 'number', min: 2, max: 12, value: n, oninput: (e) => e.target.value !== '' && setCount(e.target.value) })), + h('div', { class: 'field' }, h('span', { class: 'field-label' }, 'Parcours'), + h('div', { class: 'segmented' }, + ...[['bounce', 'Aller-retour'], ['cycle', 'En boucle']].map(([id, label]) => + h('button', { class: mode === id ? 'on' : '', onclick: () => commit(() => (getAction().mode = id)) }, label))))), + h('div', { class: 'field' }, + h('span', { class: 'field-label' }, 'Raccourci : variable L: (MobiFlight)'), + h('div', { class: 'row' }, posKey, + h('button', { class: 'btn small', style: { flex: 'none' }, onclick: fillFromVar, title: 'Position i : variable = i, et position lue dans la même variable' }, 'Remplir les positions')), + h('span', { class: 'hint' }, 'Remplit chaque position avec « variable = 0, 1, 2… » et lit la position dans cette variable. Vous pouvez ensuite modifier chaque position à la main.')), + h('div', { class: 'steps' }, ...a.positions.map((_, i) => innerActionCard(`Position ${i + 1}`, () => getAction().positions, i, `${tag}:p${i}`))), + h('div', { class: 'field sim-sync' }, + h('label', { class: 'switch' }, + h('input', { + type: 'checkbox', + checked: synced, + onchange: (e) => commit(() => { + const act = getAction(); + act.display ??= {}; + if (e.target.checked) Object.assign(act.display, { simvar: 'L:', unit: 'number' }); + else for (const k of [...SRC, 'unit', 'values']) delete act.display[k]; + }), + }), + 'Lire la position dans le simulateur (MSFS, MobiFlight ou SimHub)'), + ...(synced + ? [ + ...simSourceFields(() => getAction().display, `${tag}:display`, { unitDefault: 'number' }), + h('label', { class: 'field' }, h('span', {}, 'Valeurs lues par position (facultatif)'), + h('input', { + type: 'text', + value: Array.isArray(d.values) ? d.values.join(', ') : '', + placeholder: `vide = 0, 1, 2… ; sinon ${n} valeurs, ex. 0, 50, 100`, + oninput: (e) => commit(() => { + const vals = e.target.value.split(/[;,]/).map((x) => x.trim()).filter(Boolean).map(Number); + const act = getAction(); + if (vals.length === switchCount(act) && vals.every((v) => !Number.isNaN(v))) act.display.values = vals; + else delete act.display.values; + }, { tag: `${tag}:values`, render: 'key' }), + })), + ] + : []), + ), + h('div', { class: 'field' }, + h('span', { class: 'field-label' }, 'Visuel par position'), + matching.length + ? h('div', { class: 'row', style: { flexWrap: 'wrap' } }, + ...matching.map((g) => h('button', { + class: 'btn small', + onclick: () => commit(() => { + const act = getAction(); + act.display ??= {}; + act.display.images = switchFaces(g); + }), + }, g.label)), + Array.isArray(d.images) && d.images.length ? h('button', { class: 'btn small danger', onclick: () => commit(() => delete getAction().display.images) }, 'Retirer les visuels') : null) + : h('span', { class: 'hint' }, 'Aucun visuel fourni pour ce nombre de positions (3 ou 8 : levier, bascule, glissière, sélecteur rotatif).'), + h('span', { class: 'hint' }, 'Sans visuel, la touche affiche son titre et des repères de position.')), + ); +} + function toggleEditor(getAction, tag) { const a = getAction(); const i = state.selected; diff --git a/public/js/catalog.js b/public/js/catalog.js index e719205..b58a57b 100644 --- a/public/js/catalog.js +++ b/public/js/catalog.js @@ -2,7 +2,7 @@ import { formatHotkey, MEDIA_ACTIONS } from '/shared/keys.js'; import { h } from './dom.js'; import { faceFor } from '/shared/layout.js'; import { MSFS_PRESETS, MSFS_EVENT_LABELS, MSFS_DIAL_PRESETS, MSFS_SLIDER_PRESETS, FBW_PRESETS, RAFALE_PRESETS, RAFALE_COCKPIT_PRESETS } from '/shared/msfs.js'; -import { formatDisplay } from '/shared/controls.js'; +import { formatDisplay, switchCount, positionOf } from '/shared/controls.js'; import { SIMHUB_PRESETS } from '/shared/simhub.js'; import { aviationIcon, isFacePath, isUserIconPath, isBuiltinIconPath } from '/shared/icons.js'; @@ -207,6 +207,22 @@ export const ACTION_TYPES = { return a.same ? `2 états · ${sum(a0)}` : `${sum(a0)} ⇄ ${sum(a1)}`; }, }, + switch: { + label: 'Interrupteur', + long: 'Interrupteur à N positions', + desc: 'Chaque appui passe à la position suivante (3 positions, 8 positions…)', + icon: '🎚️', + color: '#f97316', + create: () => ({ + type: 'switch', + count: 3, + mode: 'bounce', + positions: [0, 1, 2].map(() => ({ type: 'hotkey', hotkey: { modifiers: [], key: '' }, target: { by: 'none', value: '' } })), + display: null, + }), + face: { icon: '🎚️', color: '#334155', title: 'Interrupteur' }, + summary: (a) => `${switchCount(a)} positions · ${a.mode === 'cycle' ? 'cycle' : 'aller-retour'}${a.display?.simvar || a.display?.input || a.display?.simhub ? ' · position lue dans le simulateur' : ''}`, + }, multi: { label: 'Multi', long: 'Multi-actions', @@ -256,7 +272,7 @@ export const LIBRARY = [ }, { group: 'Avancé', - items: [{ type: 'multi' }, { type: 'toggle' }, { type: 'dial' }, { type: 'slider' }], + items: [{ type: 'multi' }, { type: 'toggle' }, { type: 'switch' }, { type: 'dial' }, { type: 'slider' }], }, { group: 'MSFS 2024 (SimConnect)', @@ -428,6 +444,25 @@ function dialFace(key, live) { return el; } +// Interrupteur à N positions : un visuel par position s'il y en a, sinon libellé et repères de position. +function switchFace(key, live) { + const a = key.action; + const n = switchCount(a); + const d = a.display ?? {}; + const pos = positionOf(live.value ?? d.start ?? 0, d.values, n); + if (Array.isArray(d.images) && d.images[pos]) { + return h('div', { class: 'keyface kf-full-key' }, h('img', { class: 'kf-icon kf-full', src: d.images[pos], alt: '', draggable: 'false' })); + } + const el = h('div', { class: 'keyface' }); + el.style.setProperty('--key-color', key.color || '#334155'); + const ic = iconNode(key.icon, 'kf-icon'); + if (ic) el.append(ic); + const label = Array.isArray(d.labels) && d.labels[pos] ? d.labels[pos] : key.title; + if (key.showTitle !== false && label) el.append(h('span', { class: 'kf-title' }, label)); + el.append(h('span', { class: 'kf-state', title: `Position ${pos + 1} sur ${n}` }, ...Array.from({ length: n }, (_, i) => h('i', { class: i === pos ? 'on' : '' })))); + return el; +} + // Afficheur : grande valeur en direct, titre en dessous. function displayFace(key, live) { const el = h('div', { class: 'keyface kf-display-key' }); @@ -467,6 +502,7 @@ export function keyFace(rawKey, state = 0, live = {}) { if (rawKey.action?.type === 'dial') return dialFace(rawKey, live); if (rawKey.action?.type === 'slider') return sliderFace(rawKey, live); if (rawKey.action?.type === 'display') return displayFace(rawKey, live); + if (rawKey.action?.type === 'switch') return switchFace(rawKey, live); const key = faceFor(rawKey, state); if (isFacePath(key.icon)) return h('div', { class: 'keyface kf-full-key' }, h('img', { class: 'kf-icon kf-full', src: key.icon, alt: '', draggable: 'false' })); const showTitle = key.showTitle !== false && key.title; diff --git a/public/js/deck.js b/public/js/deck.js index 7394c2e..95cc6f3 100644 --- a/public/js/deck.js +++ b/public/js/deck.js @@ -129,11 +129,11 @@ function buildKey(pg, cell) { dataset: { index: i }, style: { gridColumn: `${cell.col + 1} / span ${cell.w}`, gridRow: `${cell.row + 1} / span ${cell.h}` }, }, - keyFace(key, toggleState(pg.id, i), key?.action?.type === 'display' ? liveOf(pg.id, i, cell) : undefined), + keyFace(key, toggleState(pg.id, i), ['display', 'switch'].includes(key?.action?.type) ? liveOf(pg.id, i, cell) : undefined), ); if (!key) return el; // Afficheur : redessiné à chaque nouvelle valeur reçue. - if (key.action?.type === 'display') el.repaint = () => el.querySelector('.keyface')?.replaceWith(keyFace(key, 0, liveOf(pg.id, i, cell))); + if (['display', 'switch'].includes(key.action?.type)) el.repaint = () => el.querySelector('.keyface')?.replaceWith(keyFace(key, 0, liveOf(pg.id, i, cell))); const isToggle = key.action?.type === 'toggle'; let timer = null; diff --git a/server/actions.js b/server/actions.js index 6651484..8a855cf 100644 --- a/server/actions.js +++ b/server/actions.js @@ -5,7 +5,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Types d'action exécutés côté serveur. Les actions de navigation ("page") // sont gérées par la surface Deck elle-même. -export const SERVER_ACTIONS = ['hotkey', 'text', 'media', 'launch', 'url', 'command', 'multi', 'delay', 'toggle', 'msfs', 'simhub', 'dial', 'slider', 'display']; +export const SERVER_ACTIONS = ['hotkey', 'text', 'media', 'launch', 'url', 'command', 'multi', 'delay', 'toggle', 'msfs', 'simhub', 'dial', 'slider', 'display', 'switch']; /** * Touche à bascule : action envoyée selon l'état courant (0 = état 1, 1 = état 2). @@ -121,6 +121,12 @@ export async function runAction(executor, action, depth = 0, ctx = {}) { if (inner.type === 'toggle') throw new Error('Une bascule ne peut pas en contenir une autre.'); return runAction(executor, inner, depth + 1, ctx); } + case 'switch': { + // Bouton « Tester » : envoie l'action de la position demandée (la première par défaut). + const inner = action.positions?.[action.testPos ?? 0]; + if (!inner?.type) throw new Error('Aucune action définie pour cette position.'); + return runAction(executor, inner, depth + 1, ctx); + } case 'dial': // Bouton « Tester » : on simule un appui (ou un cran « + » s'il n'y a pas d'action d'appui). if (action.press?.type) return runAction(executor, action.press, depth + 1, ctx); diff --git a/server/app.js b/server/app.js index ce19b19..9cdbef3 100644 --- a/server/app.js +++ b/server/app.js @@ -17,7 +17,7 @@ import { createReleaseChecker, createApkRelay } from './update.js'; import { createMsfs } from './msfs.js'; import { createSimhub } from './simhub.js'; import { normalizeVar, defaultUnit } from '../shared/msfs.js'; -import { clamp, levelToValue, valueToLevel, notchDelta, MAX_STEPS } from '../shared/controls.js'; +import { clamp, levelToValue, valueToLevel, notchDelta, switchCount, nextPosition, positionOf, MAX_STEPS } from '../shared/controls.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Actions transmises directement au simulateur (pas de frappe clavier à espacer). @@ -192,6 +192,8 @@ export async function startDeckServer({ for (const flag of ['dashes', 'managed', 'stdVar']) { if (a.display[flag]) out.push({ sk, kind: 'flag', flag, simvar: normalizeVar(a.display[flag]), unit: 'number' }); } + } else if (a?.type === 'switch' && bound(a.display)) { + out.push({ sk, kind: 'value', ...src(a.display, false), sw: { count: switchCount(a), values: a.display.values } }); } else if (a?.type === 'slider' && bound(a.sync)) { out.push({ sk, kind: 'level', ...src(a.sync, false), min: a.sync.min ?? 0, max: a.sync.max ?? 100 }); } @@ -214,6 +216,7 @@ export async function startDeckServer({ toggles.set(b.sk, on); broadcast('state', { key: b.sk, state: on }); } else if (b.kind === 'value') { + if (b.sw) toggles.setPos(b.sk, positionOf(value, b.sw.values, b.sw.count)); if (liveValues[b.sk] === value) return; liveValues[b.sk] = value; broadcast('value', { key: b.sk, value, flags: liveFlags[b.sk] ?? null }); @@ -322,6 +325,38 @@ export async function startDeckServer({ } throw httpError('Geste non pris en charge par cette touche.', 400); } + // Interrupteur à N positions : un appui envoie l'action de la position suivante (cycle ou aller-retour). + // Si une valeur du simulateur est liée à la touche, la position réelle la corrige ensuite. + const switchDir = new Map(); + async function handleSwitch(key, sk) { + const a = key.action; + const n = switchCount(a); + const { pos, dir } = nextPosition(n, a.mode, toggles.getPos(sk), switchDir.get(sk) ?? 1); + const act = a.positions?.[pos]; + if (!act?.type) throw new Error(`Aucune action définie pour la position ${pos + 1}.`); + await runAction(executor, act, 0, ctx); + switchDir.set(sk, dir); + toggles.setPos(sk, pos); + const value = a.display?.values?.[pos] ?? pos; + liveValues[sk] = value; + broadcast('value', { key: sk, value, flags: null }); + return { ok: true, position: pos }; + } + // Position mémorisée des interrupteurs à N positions (valeur affichée tant que le simulateur n'a rien envoyé). + function switchValues() { + const out = {}; + for (const p of store.config?.profiles ?? []) { + for (const pg of p.pages) { + for (const [index, key] of Object.entries(pg.keys)) { + if (key?.action?.type !== 'switch') continue; + const sk = stateKey(p.id, pg.id, index); + const pos = toggles.getPos(sk); + out[sk] = key.action.display?.values?.[pos] ?? pos; + } + } + } + return out; + } const ctx = { msfs, simhub }; const updater = customUpdater ?? createReleaseChecker({ current: VERSION, log, auto: updateCheck }); @@ -345,7 +380,7 @@ export async function startDeckServer({ checkRequestOrigin(req); switch (`${req.method} ${pathname}`) { case 'GET /api/config': - return send(res, 200, { revision, config: store.config, states: toggles.all(), levels: toggles.levels(), values: liveValues, flags: liveFlags }); + return send(res, 200, { revision, config: store.config, states: toggles.all(), levels: toggles.levels(), values: { ...switchValues(), ...liveValues }, flags: liveFlags }); case 'PUT /api/config': { requireAdmin(req); @@ -505,6 +540,16 @@ export async function startDeckServer({ throw Object.assign(e, { status: 422 }); } } + if (key.action?.type === 'switch') { + try { + const result = await handleSwitch(key, sk); + broadcast('press', { profileId, pageId, index, ok: true }); + return send(res, 200, result); + } catch (e) { + broadcast('press', { profileId, pageId, index, ok: false, error: e.message }); + throw Object.assign(e, { status: 422 }); + } + } if (key.action?.type === 'dial' || key.action?.type === 'slider') { try { const result = await handleContinuous(key, sk, input); diff --git a/server/states.js b/server/states.js index 38121ba..4a79b10 100644 --- a/server/states.js +++ b/server/states.js @@ -43,9 +43,23 @@ export class ToggleStates { return this.values[`lvl:${key}`]; } + /** Position (0, 1, 2…) d'un interrupteur à N positions. */ + getPos(key) { + const v = this.values[`pos:${key}`]; + return typeof v === 'number' ? v : 0; + } + + setPos(key, pos) { + const n = Math.max(0, Math.trunc(Number(pos) || 0)); + if (n) this.values[`pos:${key}`] = n; + else delete this.values[`pos:${key}`]; + this.scheduleSave(); + return n; + } + /** États des bascules uniquement (clé → 1). */ all() { - return Object.fromEntries(Object.entries(this.values).filter(([k]) => !k.startsWith('lvl:'))); + return Object.fromEntries(Object.entries(this.values).filter(([k]) => !k.startsWith('lvl:') && !k.startsWith('pos:'))); } /** Positions des curseurs (clé → 0..1). */ diff --git a/shared/controls.js b/shared/controls.js index 1e2f8a6..38e8661 100644 --- a/shared/controls.js +++ b/shared/controls.js @@ -60,6 +60,39 @@ export function formatDuration(seconds, decimals = 3) { return `${neg ? '−' : ''}${h ? `${h}:${String(m).padStart(2, '0')}` : m}:${s}${frac}`; } +/** Interrupteur à N positions : nombre de positions (2 à 12) d'une action « switch ». */ +export const switchCount = (a) => clamp(Math.round(Number(a?.count) || 3), 2, 12); + +/** + * Position suivante d'un interrupteur à N positions. + * - « cycle » : 0 → 1 → … → N-1 → 0 ; + * - « bounce » : aller-retour (0 → 1 → 2 → 1 → 0…), `dir` mémorise le sens (+1 / −1). + * Retourne { pos, dir }. + */ +export function nextPosition(count, mode, cur, dir = 1) { + const n = Math.max(2, count); + const c = clamp(Math.round(cur) || 0, 0, n - 1); + if (mode === 'bounce') { + let d = dir < 0 ? -1 : 1; + if (c + d > n - 1 || c + d < 0) d = -d; + return { pos: c + d, dir: d }; + } + return { pos: (c + 1) % n, dir: 1 }; +} + +/** + * Valeur lue dans le simulateur → numéro de position. Sans table `values`, la valeur est la + * position elle-même ; sinon on prend la valeur de la table la plus proche. + */ +export function positionOf(value, values, count) { + const raw = Number(value); + if (Number.isNaN(raw)) return 0; + if (Array.isArray(values) && values.length === count) { + return values.reduce((best, v, i) => (Math.abs(Number(v) - raw) < Math.abs(Number(values[best]) - raw) ? i : best), 0); + } + return clamp(Math.round(raw), 0, count - 1); +} + /** Position 0..1 d'un curseur → valeur envoyée (ex. 0..16383 pour THROTTLE_SET). */ export function levelToValue(level, min = 0, max = 16383) { return Math.round(Number(min) + clamp(Number(level) || 0, 0, 1) * (Number(max) - Number(min))); diff --git a/test/switch.test.js b/test/switch.test.js new file mode 100644 index 0000000..5ad0ef8 --- /dev/null +++ b/test/switch.test.js @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { startDeckServer } from '../server/app.js'; +import { nextPosition, positionOf, switchCount } from '../shared/controls.js'; +import { SWITCH_FACE_GROUPS } from '../shared/icons.js'; + +const quiet = { log() {}, warn() {}, error() {} }; + +test('interrupteur : position suivante en boucle et en aller-retour', () => { + assert.deepEqual([0, 1, 2, 0].map((c) => nextPosition(3, 'cycle', c).pos), [1, 2, 0, 1]); + let cur = { pos: 0, dir: 1 }; + const seen = []; + for (let i = 0; i < 6; i++) { + cur = nextPosition(3, 'bounce', cur.pos, cur.dir); + seen.push(cur.pos); + } + assert.deepEqual(seen, [1, 2, 1, 0, 1, 2]); + assert.equal(nextPosition(8, 'cycle', 7).pos, 0); + assert.equal(switchCount({ count: 99 }), 12); + assert.equal(switchCount({ count: 1 }), 2); + assert.equal(switchCount({}), 3); +}); + +test('interrupteur : position lue dans le simulateur', () => { + assert.equal(positionOf(2, undefined, 3), 2); + assert.equal(positionOf(7.4, undefined, 3), 2, 'bornée à la dernière position'); + assert.equal(positionOf(60, [0, 50, 100], 3), 1, 'valeur la plus proche'); + assert.equal(positionOf('x', undefined, 3), 0); + for (const g of SWITCH_FACE_GROUPS) assert.ok(g.positions.length >= 3); +}); + +test('serveur : un appui envoie la position suivante et la mémorise', async () => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'deck-switch-')); + const deck = await startDeckServer({ port: 0, host: '127.0.0.1', dataDir, dryRun: true, discovery: false, msfs: false, simhub: false, updateCheck: false, log: quiet }); + const url = `http://127.0.0.1:${deck.port}`; + const json = (p, o) => fetch(url + p, o).then((r) => r.json()); + try { + const { config } = await json('/api/config'); + const pg = config.profiles[0].pages[0]; + const delay = (ms) => ({ type: 'delay', ms }); + pg.keys[3] = { title: 'Sélecteur', action: { type: 'switch', count: 3, mode: 'bounce', positions: [delay(1), delay(2), delay(3)] } }; + pg.keys[4] = { title: 'Vide', action: { type: 'switch', count: 3, mode: 'cycle', positions: [delay(1), null, delay(3)] } }; + await fetch(`${url}/api/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ config }) }); + const sk = (i) => `default/${pg.id}/${i}`; + const press = (index) => + fetch(`${url}/api/press`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId: 'default', pageId: pg.id, index }) }); + + assert.equal((await json('/api/config')).values[sk(3)], 0, 'position initiale'); + const seen = []; + for (let i = 0; i < 5; i++) { + const res = await press(3); + assert.equal(res.status, 200); + seen.push((await res.json()).position); + } + assert.deepEqual(seen, [1, 2, 1, 0, 1]); + assert.equal((await json('/api/config')).values[sk(3)], 1, 'position mémorisée'); + assert.equal(Object.keys((await json('/api/config')).states).length, 0, 'ne pollue pas les états de bascule'); + + // Position sans action : refusée avec un message clair. + const bad = await press(4); + assert.equal(bad.status, 422); + assert.match((await bad.json()).error, /position 2/); + } finally { + await deck.close(); + await fs.rm(dataDir, { recursive: true, force: true }); + } +});