diff --git a/docs/BOARD_AUTHORING_GUIDE.md b/docs/BOARD_AUTHORING_GUIDE.md index 2566801..a1cfecb 100644 --- a/docs/BOARD_AUTHORING_GUIDE.md +++ b/docs/BOARD_AUTHORING_GUIDE.md @@ -151,6 +151,8 @@ Each param fills one `{placeholder}` in the template. | `default` | Value used when left blank. | | `required` | Marks the param as required (advisory). | | `pad` | Zero-pad a numeric value to a fixed width (e.g. `4` → `0025`). | +| `omittable` | The firmware accepts the command with this param left off and uses its own default (e.g. a FlthyHPs colour). `match()` accepts the command without it (the param parses as `''`); `encode()` leaves it — and every param after it — off when its value is `''`. Only omittable params may follow it in the template, and it can't also be `required`. The composer adds a **Board default** choice. | +| `leadingZeros` | The firmware reads the number with `atoi`/`toInt`, so `03` means `3`. `match()` accepts leading zeros and the step holds the canonical code, so a rebuild drops them. Needs an enum whose codes are all numeric. Set it only after reading the firmware's parser. | A param is either enum-backed **or** numeric (`type: "int"`), not both. diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index e0796db..54030ad 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -89,6 +89,8 @@ DroidNetCommandLibrary.match('A0055'); // { commandId: 'flthy.led.solid', param - `;t` is a **delay** step. - Anything `match()` doesn't recognize is preserved verbatim as a **raw** step, so unknown tokens round-trip without loss. +- An empty fragment (a stray, doubled or trailing `^`) becomes no step. The WCB + skips empty segments too, so rebuilding drops them. ```text A006^*** Flthy rainbow^;t500^T52 @@ -96,8 +98,9 @@ A006^*** Flthy rainbow^;t500^T52 ``` Round-trips are byte-identical except where a board's firmware is itself lossy -(e.g. RSeriesLogic drops leading zeros — the engine normalizes to the -firmware-equivalent canonical form). +(e.g. RSeriesLogic drops leading zeros, and PSIPro reads `4T03` as `4T3` — the +engine normalizes to the firmware-equivalent canonical form) and for empty +fragments, which are dropped. ## The visual composer (UI) diff --git a/libraries/boards/flthy-hps.json b/libraries/boards/flthy-hps.json index 8a92bdc..15fe36a 100644 --- a/libraries/boards/flthy-hps.json +++ b/libraries/boards/flthy-hps.json @@ -250,13 +250,15 @@ { "name": "color", "enum": "flthy.color", - "default": "5" + "default": "5", + "omittable": true } ], "supportsDuration": true, "examples": [ "F0025", - "A0023|20" + "A0023|20", + "F002" ], "commentLabel": "Flthy color projector", "category": "Lighting" @@ -276,7 +278,8 @@ { "name": "color", "enum": "flthy.color", - "default": "5" + "default": "5", + "omittable": true }, { "name": "speed", @@ -284,13 +287,15 @@ "min": 0, "max": 9, "default": 5, - "required": true + "omittable": true } ], "supportsDuration": true, "examples": [ "A00365", - "F00362|30" + "F00362|30", + "A003", + "A0031" ], "commentLabel": "Flthy dim pulse", "category": "Lighting" @@ -310,13 +315,15 @@ { "name": "color", "enum": "flthy.color", - "default": "5" + "default": "5", + "omittable": true } ], "supportsDuration": true, "examples": [ "F0043", - "A0045|20" + "A0045|20", + "F004" ], "commentLabel": "Flthy cycle", "category": "Lighting" @@ -336,13 +343,15 @@ { "name": "color", "enum": "flthy.color", - "default": "7" + "default": "7", + "omittable": true } ], "supportsDuration": true, "examples": [ "A0077", - "F0075|10" + "F0075|10", + "A007|240" ], "commentLabel": "Flthy short circuit", "category": "Lighting" @@ -389,7 +398,8 @@ { "name": "color", "enum": "flthy.color", - "default": "5" + "default": "5", + "omittable": true } ], "supportsDuration": true, @@ -397,7 +407,8 @@ "A0055", "R0053", "A0055|60", - "X0055" + "X0055", + "A005" ], "commentLabel": "Flthy solid", "category": "Lighting" @@ -462,9 +473,10 @@ "required": true } ], - "supportsDuration": false, + "supportsDuration": true, "examples": [ - "F102" + "F102", + "F102|10" ], "commentLabel": "Flthy RC L/R", "category": "Servo" @@ -482,9 +494,10 @@ "required": true } ], - "supportsDuration": false, + "supportsDuration": true, "examples": [ - "F103" + "F103", + "F103|10" ], "commentLabel": "Flthy RC U/D", "category": "Servo" @@ -523,9 +536,10 @@ "required": true } ], - "supportsDuration": false, + "supportsDuration": true, "examples": [ - "F105" + "F105", + "F105|3" ], "commentLabel": "Flthy wag L/R", "category": "Servo" @@ -543,9 +557,10 @@ "required": true } ], - "supportsDuration": false, + "supportsDuration": true, "examples": [ - "F106" + "F106", + "F106|3" ], "commentLabel": "Flthy wag U/D", "category": "Servo" diff --git a/libraries/boards/ia-magic-panel.json b/libraries/boards/ia-magic-panel.json index 167d861..f471af7 100644 --- a/libraries/boards/ia-magic-panel.json +++ b/libraries/boards/ia-magic-panel.json @@ -72,7 +72,8 @@ { "name": "mode", "enum": "iamp.mode", - "required": true + "required": true, + "leadingZeros": true } ], "examples": [ diff --git a/libraries/boards/psi-pro.json b/libraries/boards/psi-pro.json index a398f4f..f3e0cb6 100644 --- a/libraries/boards/psi-pro.json +++ b/libraries/boards/psi-pro.json @@ -78,7 +78,7 @@ "template": "{address}T{mode}", "params": [ { "name": "address", "enum": "psi.address", "required": true }, - { "name": "mode", "enum": "psi.mode", "required": true } + { "name": "mode", "enum": "psi.mode", "required": true, "leadingZeros": true } ], "supportsDuration": true, "examples": ["0T18", "4T92", "5T3", "0T1", "0T11|47"], diff --git a/libraries/manifest.json b/libraries/manifest.json index 32dd96a..b259113 100644 --- a/libraries/manifest.json +++ b/libraries/manifest.json @@ -1,6 +1,6 @@ { "$schema": "droidnet-command-library/catalog/v1", - "libraryVersion": "4.4.0", + "libraryVersion": "4.5.0", "schemaVersion": "v1", "generatedFrom": "C2B5 firmware + WIPDocs (2026-06); R2UppitySpinnerV3 ALT fw 3.5.3 (2026-07); Printed Droid Magic Panel 32 Wireless KB (2026-07); WCB 6.1.5_290119RJUN2026 (2026-07); WCB 6.2.1_021242RSEP2026 + WcbCmd 0.8.0 (2026-09); DroidNet forks (2026-09)", "boards": [ diff --git a/package-lock.json b/package-lock.json index f1295df..d00dbe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "droidnet-command-library", - "version": "4.4.0", + "version": "4.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "droidnet-command-library", - "version": "4.4.0", + "version": "4.5.0", "license": "MPL-2.0", "devDependencies": { "ajv": "^8.17.1", diff --git a/package.json b/package.json index e05471d..f936fef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "droidnet-command-library", - "version": "4.4.0", + "version": "4.5.0", "description": "Schema-driven encoder/decoder and visual composer for building serial commands to control a vibrant community of Astromech droid components. Anyone can add their board by editing a JSON library — no code changes required.", "license": "MPL-2.0", "keywords": [ diff --git a/releases.json b/releases.json index 0bec4c8..4bb2c1b 100644 --- a/releases.json +++ b/releases.json @@ -2,17 +2,17 @@ "$schema": "droidnet-command-library/releases/v1", "_comment": "Update pointer for the 'Update Library' flow. A device fetches this file, compares latest.libraryVersion to its installed catalog, and (if newer) downloads latest.url (the catalog manifest), then fetches each board listed in manifest.boards in order, validates them, and loads them via loadLibrary(boards, { libraryVersion }).", "latest": { - "libraryVersion": "4.4.0", + "libraryVersion": "4.5.0", "schemaVersion": "v1", - "releasedAt": "2026-09-23", + "releasedAt": "2026-09-29", "url": "https://raw.githubusercontent.com/travisccook/droidnet-command-library/main/libraries/manifest.json", - "notes": "Minor: DroidNet fork boards. Adds two device-native boards, each listed right after its stock board. RSeriesLogic · DroidNet fork (droidnet-rseries-logic) has Brightness (rseries.cfg.brightness): #LEBRI{unit}{level}, where the unit is none (both displays), F (front) or R (rear) and the level is 1-255. FlthyHPs · DroidNet fork (droidnet-flthy-hps) has Brightness (flthy.led.brightness): H{unit}B{level}, where the unit is F, R, T, X, Y, Z or A (all) and the level is 1-255. Both need the DroidNet fork firmware named in the board's firmware field; stock RSeriesLogic and FlthyHPs firmware ignore them. The level is RAM-only: nothing is saved on the board, and a power cycle restores the power-on brightness. The stock RSeriesLogic and FlthyHPs boards are unchanged. Board catalog 22 -> 24, commands 500 -> 502. Safety: new class `storedcommand` for commands that run a command the user saved on the WCB (;C/;SEQ run-sequence, and the DFPlayer/MP3 play+ONFIN callbacks, which were cosmetic). Queries, save and clear stay config." + "notes": "Minor: parse what the firmware accepts. The composer now recognizes stored commands it showed as unrecognized raw steps. Engine: an empty fragment (a stray, doubled or trailing ^) becomes no step, since the WCB skips empty segments too. Two new param flags: omittable (the firmware accepts the command with the param left off and uses its own default; the composer offers a Board default choice) and leadingZeros (the firmware reads the number with atoi/toInt, so 03 is 3). FlthyHPs Holoprojectors (flthy-hps): the colour is omittable on Color Projector, Dim Pulse, Cycle, Solid Color and Short Circuit, and the speed on Dim Pulse, so A007 decodes as Short Circuit in the board's own colour and A0031 as Dim Pulse at the board's speed. RC Control L/R and U/D and Wag L/R and U/D accept a |n duration (FlthyHPs v1.81 stops them after n seconds). PSIPro (psi-pro) Run Effect and IA MagicPanel (ia-magic-panel) Run Mode accept leading zeros (4T03, T00). Saved values still decode losslessly; rebuilding a value writes the canonical form (4T3, no empty segments), which the firmware runs identically. No ids, templates or params change; board and command counts are unchanged." }, "libraries": [ { "id": "droidnet-astromech", "name": "DroidNet Astromech Boards", - "libraryVersion": "4.4.0", + "libraryVersion": "4.5.0", "schemaVersion": "v1", "url": "https://raw.githubusercontent.com/travisccook/droidnet-command-library/main/libraries/manifest.json" } diff --git a/schema/library.schema.json b/schema/library.schema.json index fe04bce..b465329 100644 --- a/schema/library.schema.json +++ b/schema/library.schema.json @@ -170,6 +170,8 @@ "default": { "type": ["string", "number"], "description": "Default code/value used when the param is left blank. Strings for enum codes; numbers are accepted for int params." }, "required": { "type": "boolean" }, "pad": { "type": "integer", "minimum": 1, "description": "Zero-pad a numeric value to this fixed width (e.g. 4 → '0025')." }, + "omittable": { "type": "boolean", "description": "The firmware accepts the command with this trailing param left off and uses its own default (e.g. a FlthyHPs colour). match() accepts the command without it (the param parses as ''); encode() leaves it, and every param after it, off the wire when its value is ''. Only omittable params may follow it in the template. The composer offers a 'Board default' choice." }, + "leadingZeros": { "type": "boolean", "description": "The firmware reads this number with atoi/toInt, so leading zeros do not change it ('03' is '3'). match() accepts them and the step holds the canonical code, so a rebuild drops them (firmware-equivalent, not byte-identical). Needs an enum whose codes are all numeric." }, "pattern": { "type": "string", "description": "Regex fragment this free-text param matches so its wire value round-trips (e.g. '[0-9A-Fa-f]+' for a hex bitmask, '.+' for arbitrary text). Use ONLY non-capturing groups '(?:...)' — a capturing group would break param↔capture-group indexing. Renders as a text input." } }, "additionalProperties": true diff --git a/scripts/validate.js b/scripts/validate.js index 5c1e614..cb729b4 100644 --- a/scripts/validate.js +++ b/scripts/validate.js @@ -88,6 +88,27 @@ function boardSemanticErrors(lib) { for (const name of paramNames) { if (!placeholders.includes(name)) warnings.push(`${where}: param '${name}' is never used in the template`); } + // omittable params are left off the END of the wire string (the firmware reads them + // by position), so from the first one on the template may hold only omittable + // placeholders — no literal text, no required param. + const byName = new Map((cmd.params || []).map(p => [p.name, p])); + const firstOmit = [...cmd.template.matchAll(/\{(\w+)\}/g)].find(m => (byName.get(m[1]) || {}).omittable); + if (firstOmit) { + const tail = cmd.template.slice(firstOmit.index); + const tailNames = [...tail.matchAll(/\{(\w+)\}/g)].map(m => m[1]); + if (tail.replace(/\{\w+\}/g, '') !== '' || tailNames.some(n => !(byName.get(n) || {}).omittable)) { + errors.push(`${where}: omittable param '${firstOmit[1]}' must be followed only by other omittable params (no literal text or required param after it)`); + } + } + } + for (const p of cmd.params || []) { + if (p.omittable && p.required) errors.push(`${where}: param '${p.name}' cannot be both omittable and required`); + if (p.leadingZeros) { + const codes = p.enum && enums[p.enum] ? (enums[p.enum].values || []).map(x => String(x.code)) : null; + if (!codes || !codes.every(c => /^\d+$/.test(c))) { + errors.push(`${where}: leadingZeros on param '${p.name}' needs an enum whose codes are all numeric`); + } + } } // commentLabel may interpolate {param} placeholders (→ selected value labels) and // [ ... ] optional segments; every placeholder must reference a real param. diff --git a/src/droidnet-command-library-ui.js b/src/droidnet-command-library-ui.js index 98a9619..91c66d3 100644 --- a/src/droidnet-command-library-ui.js +++ b/src/droidnet-command-library-ui.js @@ -58,6 +58,29 @@ return humanize(p.name); } + // The input for one param, holding `val`. An omittable param (the firmware uses its own + // default when it is left off) gets a 'Board default' choice whose value is '' — the + // engine then leaves it off the wire. + function paramControlHtml(p, val, getEnum) { + const name = esc(p.name); + const cap = esc(captionFor(p, getEnum)); + if (p.enum) { + const en = getEnum(p.enum); + const opt = (code, label) => ``; + const opts = (p.omittable ? opt('', 'Board default') : '') + + ((en && en.values) || []).map(v => opt(v.code, v.label)).join(''); + return ``; + } + if (p.pattern) { + // free-text param (e.g. a display string or hex bitmask) → text input, not a number spinner + return ``; + } + const min = p.min !== undefined ? ` min="${p.min}"` : ''; + const max = p.max !== undefined ? ` max="${p.max}"` : ''; + const ph = p.omittable ? ' placeholder="Board default"' : ''; + return ``; + } + // Standard category display order (see spec 2026-07-07-command-categories-design). const STANDARD_CATEGORY_ORDER = ['Lighting', 'Movement', 'Sound', 'Sequences', 'Setup', 'Config', 'Power', 'System']; @@ -261,23 +284,7 @@ function paramControl(p, cur) { const val = cur[p.name] !== undefined ? cur[p.name] : (p.default !== undefined ? p.default : ''); - const name = esc(p.name); - const cap = captionFor(p, E().getEnum); - let control; - if (p.enum) { - const en = E().getEnum(p.enum); - const opts = ((en && en.values) || []) - .map(v => ``).join(''); - control = ``; - } else if (p.pattern) { - // free-text param (e.g. a display string or hex bitmask) → text input, not a number spinner - control = ``; - } else { - const min = p.min !== undefined ? ` min="${p.min}"` : ''; - const max = p.max !== undefined ? ` max="${p.max}"` : ''; - control = ``; - } - return fieldCell(cap, control); + return fieldCell(captionFor(p, E().getEnum), paramControlHtml(p, val, E().getEnum)); } function loadStepIntoAddBar(i) { @@ -378,5 +385,5 @@ renderAddBar(); } - return { renderComposer, stepLabel, humanize, captionFor, groupCommandsForDropdown }; + return { renderComposer, stepLabel, humanize, captionFor, groupCommandsForDropdown, paramControlHtml }; }); diff --git a/src/droidnet-command-library.js b/src/droidnet-command-library.js index 96449aa..47e0c53 100644 --- a/src/droidnet-command-library.js +++ b/src/droidnet-command-library.js @@ -231,12 +231,17 @@ }, template: { encode(cmd, params, opts) { + let omitted = false; let s = cmd.template.replace(/\{(\w+)\}/g, (_, name) => { const v = params[name]; + const p = (cmd.params || []).find((x) => x.name === name); + // An omittable param set to '' is left off the wire, and so is every param after + // it: the firmware reads trailing params by position and uses its own default for + // any it does not get. (The validator keeps omittable params at the template's end.) + if (p && p.omittable && (omitted || v === '')) { omitted = true; return ''; } // treat an explicit '' the same as absent → use the param default let out = (v !== undefined && v !== '') ? String(v) : String(_paramDefault(cmd, name)); // zero-pad numeric params that declare a fixed width (e.g. HCR WAV file 0000-9999) - const p = (cmd.params || []).find((x) => x.name === name); if (p && p.pad && /^\d+$/.test(out)) out = out.padStart(p.pad, '0'); return out; }); @@ -271,26 +276,32 @@ function _buildTemplateMatcher(cmd) { const groups = []; + let omittable = 0; const pattern = cmd.template.replace(/\{(\w+)\}|([^{]+)/g, (m, name, lit) => { if (lit !== undefined) return _escapeRe(lit); const p = (cmd.params || []).find(x => x.name === name); groups.push(name); + // An omittable param opens an optional group that closes at the end of the + // template, so a later param can only be present when this one is. + const open = p && p.omittable ? (omittable++, '(?:') : ''; if (p && p.enum) { const codes = (getEnum(p.enum).values || []).map(v => _escapeRe(v.code)) .sort((a, b) => b.length - a.length); // longest-first so multi-char codes win - return '(' + codes.join('|') + ')'; + // leadingZeros: the firmware reads the number with atoi/toInt, so '03' is '3'. The + // zeros sit outside the capture group, so the step holds the canonical code. + return open + (p.leadingZeros ? '0*' : '') + '(' + codes.join('|') + ')'; } if (p && p.pattern) { // Board-supplied regex fragment (e.g. a hex bitmask '[0-9A-Fa-f]+') so // non-numeric free-text values still round-trip. MUST use only non-capturing // groups '(?:...)' — a capturing group would shift param↔capture-group indexing. - return '(' + p.pattern + ')'; + return open + '(' + p.pattern + ')'; } // Allow an optional leading '-' so int params with a negative range // (e.g. a rotary speed of -80) re-parse to a structured step, not a raw one. - return '(-?\\d+)'; + return open + '(-?\\d+)'; }); - return { re: new RegExp('^' + pattern + '$'), groups }; + return { re: new RegExp('^' + pattern + ')?'.repeat(omittable) + '$'), groups }; } // template.match added to the registry: @@ -302,7 +313,7 @@ const m = cmd._matcher.re.exec(token); if (!m) continue; const params = {}; - cmd._matcher.groups.forEach((g, i) => { params[g] = m[i + 1]; }); + cmd._matcher.groups.forEach((g, i) => { params[g] = m[i + 1] === undefined ? '' : m[i + 1]; }); // '' = omitted return { commandId: cmd.id, params }; } } @@ -352,6 +363,9 @@ const steps = []; const frags = String(value).split('^'); for (const frag of frags) { + // An empty segment (a stray, doubled or trailing '^') carries no command; the WCB + // skips it too, so it becomes no step. Rebuilding drops it (firmware-equivalent). + if (frag === '') continue; if (frag.startsWith('***')) { const text = frag.slice(3); const prev = steps[steps.length - 1]; diff --git a/test/engine.test.js b/test/engine.test.js index 847cdbb..51a4570 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -19,7 +19,7 @@ describe('engine lookups', () => { expect(cb.getComponents().map(c => c.id)).toEqual(expect.arrayContaining(['flthy-hps', 'ia-magic-panel'])); }); test('getLibraryVersion reports the loaded version', () => { - expect(cb.getLibraryVersion()).toBe('4.4.0'); + expect(cb.getLibraryVersion()).toBe('4.5.0'); }); test('getCommand resolves and back-links its component', () => { const cmd = cb.getCommand('flthy.led.solid'); diff --git a/test/load-node.test.js b/test/load-node.test.js index 7e3aa11..fe52b66 100644 --- a/test/load-node.test.js +++ b/test/load-node.test.js @@ -3,20 +3,20 @@ const { readCatalog, loadCatalog } = require('../src/load-node.js'); test('readCatalog returns the manifest and every board, in order', () => { const { manifest, boards } = readCatalog(); - expect(manifest.libraryVersion).toBe('4.4.0'); + expect(manifest.libraryVersion).toBe('4.5.0'); expect(boards).toHaveLength(manifest.boards.length); expect(boards.map(b => b.components[0].id)).toEqual(manifest.boards.map(b => b.id)); }); test('loadCatalog({ load: false }) returns the merged catalog object', () => { const lib = loadCatalog({ load: false }); - expect(lib.libraryVersion).toBe('4.4.0'); + expect(lib.libraryVersion).toBe('4.5.0'); expect(lib.components.length).toBe(24); }); test('loadCatalog() loads the engine and resolves commands', () => { const engine = loadCatalog(); - expect(engine.getLibraryVersion()).toBe('4.4.0'); + expect(engine.getLibraryVersion()).toBe('4.5.0'); expect(engine.getCommand('flthy.led.solid')).not.toBeNull(); expect(engine.encode(engine.getCommand('flthy.led.solid'), { designator: 'A', color: '5' }, {})).toBe('A0055'); }); diff --git a/test/parse-leniency.test.js b/test/parse-leniency.test.js new file mode 100644 index 0000000..d7b4d99 --- /dev/null +++ b/test/parse-leniency.test.js @@ -0,0 +1,214 @@ +'use strict'; +// 4.5.0: parse what the firmware accepts. Stored WCB commands written by hand (or by +// older tools) use spellings the firmware treats as equal to the canonical form — a +// stray '^', '4T03' for '4T3', 'A007' with the colour left off. The builder showed each +// as an unrecognized raw step. These tests pin the leniency to what the firmware does. +const { readCatalog } = require('../src/load-node.js'); +const v = require('../scripts/validate.js'); +const UI = require('../src/droidnet-command-library-ui.js'); + +function loadEngine() { + jest.resetModules(); + return require('../src/droidnet-command-library.js'); +} +const { manifest, boards } = readCatalog(); +function loadCatalog(cb) { + cb.loadLibrary(boards.map(b => JSON.parse(JSON.stringify(b))), { libraryVersion: manifest.libraryVersion }); +} +const raws = (steps) => steps.filter(s => s.type === 'raw').map(s => s.text); + +const LIB = { + enums: { + mode: { values: [{ code: '0', label: 'Off' }, { code: '3', label: 'Alarm' }, { code: '92', label: 'VU' }] }, + color: { values: [{ code: '1', label: 'Red' }, { code: '5', label: 'Blue' }] }, + }, + components: [{ + id: 'test', name: 'Test', kind: 'device-native', + routing: { class: 'broadcast', nativeWrapper: 'none', durationSuffix: { supported: true, sep: '|' } }, + commands: [ + { id: 't.mode', name: 'Mode', template: '4T{mode}', supportsDuration: true, + params: [{ name: 'mode', enum: 'mode', leadingZeros: true }], examples: ['4T3'] }, + { id: 't.strict', name: 'Strict', template: 'S{mode}', + params: [{ name: 'mode', enum: 'mode' }], examples: ['S3'] }, + { id: 't.pulse', name: 'Pulse', template: 'A003{color}{speed}', supportsDuration: true, + params: [{ name: 'color', enum: 'color', default: '5', omittable: true }, + { name: 'speed', type: 'int', min: 0, max: 9, default: 5, omittable: true }], + examples: ['A00315'] }, + ], + }], +}; + +describe('parseWCBValue — empty fragments', () => { + let cb; + beforeEach(() => { cb = loadEngine(); cb.loadLibrary(LIB, { libraryVersion: 'test' }); }); + + test('a trailing ^ adds no step (the WCB skips empty segments)', () => { + const steps = cb.parseWCBValue('4T3^*** note^'); + expect(steps).toHaveLength(1); + expect(steps[0]).toMatchObject({ type: 'command', commandId: 't.mode', label: ' note' }); + }); + test('doubled and leading ^ add no steps', () => { + expect(raws(cb.parseWCBValue('^4T3^^S3^^'))).toEqual([]); + expect(cb.parseWCBValue('^4T3^^S3^^')).toHaveLength(2); + }); + test('an empty value parses to no steps', () => { + expect(cb.parseWCBValue('')).toEqual([]); + }); + test('rebuilding drops the empty segments (firmware-equivalent)', () => { + expect(cb.buildWCBValue(cb.parseWCBValue('4T3^*** note^'))).toBe('4T3^*** note'); + }); +}); + +describe('leadingZeros params', () => { + let cb; + beforeEach(() => { cb = loadEngine(); cb.loadLibrary(LIB, { libraryVersion: 'test' }); }); + + test('match accepts leading zeros and returns the canonical code', () => { + expect(cb.match('4T03')).toMatchObject({ commandId: 't.mode', params: { mode: '3' } }); + expect(cb.match('4T092')).toMatchObject({ params: { mode: '92' } }); + }); + test('zero itself still matches, with or without extra zeros', () => { + expect(cb.match('4T0')).toMatchObject({ params: { mode: '0' } }); + expect(cb.match('4T00')).toMatchObject({ params: { mode: '0' } }); + }); + test('keeps working with a duration suffix', () => { + expect(cb.match('4T03|15')).toMatchObject({ commandId: 't.mode', params: { mode: '3' }, duration: 15 }); + }); + test('rebuild writes the canonical form (firmware-equivalent)', () => { + expect(cb.buildWCBValue(cb.parseWCBValue('4T03'))).toBe('4T3'); + }); + test('params without the flag stay strict', () => { + expect(cb.match('S03')).toBeNull(); + expect(cb.match('S3')).toMatchObject({ commandId: 't.strict' }); + }); + test('an unknown code with leading zeros is still unrecognized', () => { + expect(cb.match('4T07')).toBeNull(); + }); +}); + +describe('omittable params', () => { + let cb; + beforeEach(() => { cb = loadEngine(); cb.loadLibrary(LIB, { libraryVersion: 'test' }); }); + + test('match accepts the command with trailing params left off', () => { + expect(cb.match('A003')).toMatchObject({ commandId: 't.pulse', params: { color: '', speed: '' } }); + expect(cb.match('A0031')).toMatchObject({ commandId: 't.pulse', params: { color: '1', speed: '' } }); + expect(cb.match('A00315')).toMatchObject({ commandId: 't.pulse', params: { color: '1', speed: '5' } }); + }); + test('a later param cannot be present without the earlier one', () => { + // 'A0037' — 7 is not a colour; the firmware would read it as one, so it is not a speed either + expect(cb.match('A0037')).toBeNull(); + }); + test('parse → build is byte-identical', () => { + for (const s of ['A003', 'A0031', 'A00315', 'A003|30', 'A0031|240']) { + expect(cb.buildWCBValue(cb.parseWCBValue(s))).toBe(s); + } + }); + test("encode: '' omits the param and everything after it", () => { + const cmd = cb.getCommand('t.pulse'); + expect(cb.encode(cmd, { color: '', speed: '' })).toBe('A003'); + expect(cb.encode(cmd, { color: '1', speed: '' })).toBe('A0031'); + expect(cb.encode(cmd, { color: '', speed: '7' })).toBe('A003'); + }); + test('encode: an absent value still falls back to the default', () => { + expect(cb.encode(cb.getCommand('t.pulse'), {})).toBe('A00355'); + }); +}); + +describe('validator — omittable and leadingZeros', () => { + const board = (commands, enums) => ({ enums: enums || { c: { values: [{ code: '1', label: 'One' }] } }, + components: [{ id: 'a', name: 'A', kind: 'device-native', commands }] }); + + test('accepts omittable params at the end of the template', () => { + const lib = board([{ id: 'a.x', name: 'X', category: 'Lighting', template: 'X{p}{q}', + params: [{ name: 'p', enum: 'c', omittable: true }, { name: 'q', type: 'int', omittable: true }] }]); + expect(v.boardSemanticErrors(lib).errors).toEqual([]); + }); + test('rejects literal text after an omittable param', () => { + const lib = board([{ id: 'a.x', name: 'X', template: 'X{p}Y', params: [{ name: 'p', enum: 'c', omittable: true }] }]); + expect(v.boardSemanticErrors(lib).errors.join(' ')).toMatch(/omittable/i); + }); + test('rejects a required param after an omittable one', () => { + const lib = board([{ id: 'a.x', name: 'X', template: 'X{p}{q}', + params: [{ name: 'p', enum: 'c', omittable: true }, { name: 'q', type: 'int' }] }]); + expect(v.boardSemanticErrors(lib).errors.join(' ')).toMatch(/omittable/i); + }); + test('rejects a param that is both omittable and required', () => { + const lib = board([{ id: 'a.x', name: 'X', template: 'X{p}', params: [{ name: 'p', enum: 'c', omittable: true, required: true }] }]); + expect(v.boardSemanticErrors(lib).errors.join(' ')).toMatch(/omittable.*required|required.*omittable/i); + }); + test('rejects leadingZeros on a free-text pattern param', () => { + const lib = board([{ id: 'a.x', name: 'X', template: 'X{p}', params: [{ name: 'p', pattern: '.+', leadingZeros: true }] }]); + expect(v.boardSemanticErrors(lib).errors.join(' ')).toMatch(/leadingZeros/); + }); + test('rejects leadingZeros on an enum with a non-numeric code', () => { + const lib = board([{ id: 'a.x', name: 'X', template: 'X{p}', params: [{ name: 'p', enum: 'e', leadingZeros: true }] }], + { e: { values: [{ code: 'A', label: 'A' }] } }); + expect(v.boardSemanticErrors(lib).errors.join(' ')).toMatch(/leadingZeros/); + }); +}); + +describe('composer — omittable enum params', () => { + const en = { values: [{ code: '1', label: 'Red' }, { code: '5', label: 'Blue' }] }; + const getEnum = () => en; + + test('offers a Board default choice, selected for a blank value', () => { + const html = UI.paramControlHtml({ name: 'color', enum: 'c', omittable: true }, '', getEnum); + expect(html).toMatch(/