diff --git a/CLAUDE.md b/CLAUDE.md index 4cc0248..7442814 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,13 +109,17 @@ or `parse`. ### The wire format -Steps join with `^`. A `***`-prefixed fragment is an inline comment: it becomes -the **label** of the preceding command/raw step, or a standalone comment step if -there's nothing to attach to. `;t` is a delay. Anything `match` doesn't -recognize survives as a `raw` step (lossless). The core invariant, exercised by -tests against `test/fixtures/commands.sample.json`: -`buildWCBValue(parseWCBValue(v)) === v` (byte-identical, except the documented -rseries leading-zero canonicalization). +Steps join with `^`. A `***`-prefixed fragment is a comment. Directly after a +command/raw step (`^***`) it is that step's **label**. After an empty segment +(`^^***`) it is a note on its own line. After a delay, a labelled step or a note +it is a note marked `inline: true`, written back with a single `^`. First in the +value it is a plain note. `;t` is a delay. Anything `match` doesn't +recognize survives as a `raw` step (lossless). Any other empty segment (a stray, +doubled or trailing `^`) becomes no step; the WCB skips it too. The core +invariant, exercised by tests against `test/fixtures/commands.sample.json`: +`buildWCBValue(parseWCBValue(v)) === v` (byte-identical, except that the engine +writes a firmware-lossy spelling in its canonical form, such as RSeries and +`leadingZeros` params dropping leading zeros, and drops those empty segments). ## Working in this repo diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index 54030ad..73af5fe 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -84,13 +84,19 @@ DroidNetCommandLibrary.match('A0055'); // { commandId: 'flthy.led.solid', param `buildWCBValue` / `parseWCBValue` use a compact, line-free format: - Steps are joined by `^`. -- A `***text` fragment is a **comment**. Immediately after a command/raw step it - becomes that step's inline **label**; otherwise it's a standalone note. +- A `***text` fragment is a **comment**. Directly after a command/raw step + (`^***`) it becomes that step's inline **label**. After an empty segment + (`^^***`) it is a standalone **note** on its own line. After a delay, a + labelled step or another note (a single `^`) it is a standalone note marked + `inline: true`, written back with a single `^`. First in the value it is a + plain note. `buildWCBValue` writes any other note that follows a step as + `^^***text`. - `;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. + skips empty segments too, so rebuilding drops them, except the one in `^^***`, + which keeps a note on its own line. ```text A006^*** Flthy rainbow^;t500^T52 @@ -100,7 +106,7 @@ 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, and PSIPro reads `4T03` as `4T3` — the engine normalizes to the firmware-equivalent canonical form) and for empty -fragments, which are dropped. +fragments, which are dropped (except the one before a note on its own line). ## The visual composer (UI) @@ -144,6 +150,12 @@ engine being loaded first. wire string. This is your source of truth; store it. - **callbacks.onTest()** — optional; called if you add a test affordance. +`renderComposer` returns a handle, `{ setValue(value) }`, for a host that also +lets the user edit the wire value as text. `setValue` does nothing when `value` +equals the composer's compiled value; otherwise it re-parses the steps, cancels a +step being edited in the add bar, and re-renders the step list, length and +safety note, keeping a half-filled add bar. It never calls `onChange`. + The composer handles add/edit/remove, drag-reorder, inline delays and notes, a length counter, and a safety warning when any non-`cosmetic` command is present. If you build your own UI instead of using the composer, show any `safety` value diff --git a/libraries/manifest.json b/libraries/manifest.json index b259113..530c1d3 100644 --- a/libraries/manifest.json +++ b/libraries/manifest.json @@ -1,6 +1,6 @@ { "$schema": "droidnet-command-library/catalog/v1", - "libraryVersion": "4.5.0", + "libraryVersion": "4.6.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 d00dbe3..e5f6541 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "droidnet-command-library", - "version": "4.5.0", + "version": "4.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "droidnet-command-library", - "version": "4.5.0", + "version": "4.6.0", "license": "MPL-2.0", "devDependencies": { "ajv": "^8.17.1", - "jest": "^29.7.0" + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0" } }, "node_modules/@babel/code-frame": { @@ -905,6 +906,16 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -987,6 +998,18 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jsdom": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", + "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/node": { "version": "26.0.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", @@ -1004,6 +1027,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -1021,6 +1051,64 @@ "dev": true, "license": "MIT" }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -1104,6 +1192,13 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1315,6 +1410,20 @@ "dev": true, "license": "MIT" }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1459,6 +1568,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1510,6 +1632,48 @@ "node": ">= 8" } }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1528,6 +1692,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -1553,6 +1724,16 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -1573,6 +1754,35 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.379", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz", @@ -1600,6 +1810,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -1610,6 +1833,16 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1620,6 +1853,35 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1640,6 +1902,28 @@ "node": ">=8" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -1654,6 +1938,26 @@ "node": ">=4" } }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -1772,6 +2076,23 @@ "node": ">=8" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1824,6 +2145,31 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -1834,6 +2180,20 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -1869,6 +2229,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1886,6 +2259,35 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1899,6 +2301,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -1906,6 +2321,35 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -1916,6 +2360,19 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -2018,6 +2475,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -2322,6 +2786,34 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-environment-jsdom": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", + "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/jsdom": "^20.0.0", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0", + "jsdom": "^20.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -2739,6 +3231,52 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2868,6 +3406,16 @@ "tmpl": "1.0.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -2889,6 +3437,29 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -2966,6 +3537,13 @@ "node": ">=8" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3066,6 +3644,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3188,6 +3779,29 @@ "node": ">= 6" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -3205,6 +3819,13 @@ ], "license": "MIT" }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -3232,6 +3853,13 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3287,6 +3915,26 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3486,6 +4134,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -3521,6 +4176,35 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -3551,6 +4235,16 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3582,6 +4276,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -3597,6 +4302,19 @@ "node": ">=10.12.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -3607,6 +4325,54 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3662,6 +4428,45 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index f936fef..7708bd1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "droidnet-command-library", - "version": "4.5.0", + "version": "4.6.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": [ @@ -39,6 +39,7 @@ }, "devDependencies": { "ajv": "^8.17.1", - "jest": "^29.7.0" + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0" } } diff --git a/releases.json b/releases.json index 4bb2c1b..2a9d7d4 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.5.0", + "libraryVersion": "4.6.0", "schemaVersion": "v1", - "releasedAt": "2026-09-29", + "releasedAt": "2026-10-02", "url": "https://raw.githubusercontent.com/travisccook/droidnet-command-library/main/libraries/manifest.json", - "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." + "notes": "Minor: comments keep their place, and the composer can follow a host's text field. Engine: a *** comment after an empty segment (^^***) is a standalone note step on its own line instead of the previous step's label, and buildWCBValue writes a note step that follows another step as ^^***text (a note first in the value stays ***text). A comment parsed from a single ^ after a delay, a labelled step or another note carries inline: true and is written back with one ^, so those values rebuild byte for byte. A comment directly after a command or raw step (^***) is still that step's label. The WCB skips empty segments and comments alike, so the firmware runs the same commands. + Note after a step now writes ^^*** (its own line) instead of ^*** (which read back as the step's label). UI: renderComposer returns { setValue(value) }: equal to the compiled steps it does nothing; otherwise it re-parses the steps, cancels a step being edited in the add bar and re-renders the step list, length and safety note, keeping a half-filled add bar. It never calls onChange; callers that ignore the return value are unaffected. No ids, templates or params change; board and command counts are unchanged." }, "libraries": [ { "id": "droidnet-astromech", "name": "DroidNet Astromech Boards", - "libraryVersion": "4.5.0", + "libraryVersion": "4.6.0", "schemaVersion": "v1", "url": "https://raw.githubusercontent.com/travisccook/droidnet-command-library/main/libraries/manifest.json" } diff --git a/src/droidnet-command-library-ui.js b/src/droidnet-command-library-ui.js index 91c66d3..f8a5239 100644 --- a/src/droidnet-command-library-ui.js +++ b/src/droidnet-command-library-ui.js @@ -161,12 +161,15 @@ function compiled() { return E().buildWCBValue(steps); } function recompile() { const v = compiled(); if (callbacks.onChange) callbacks.onChange(v); return v; } + // Leave edit mode: forget the step loaded into the add bar and everything kept for its + // label. The caller re-renders the add bar (and the step list, for the .editing highlight). + function cancelEdit() { editIndex = null; editLabel = undefined; editWasAuto = false; seed = null; } function moveStep(from, to) { if (from === null || to === null || from === to || from < 0 || to < 0 || from >= steps.length || to >= steps.length) return; const moved = steps.splice(from, 1)[0]; steps.splice(to, 0, moved); - if (editIndex !== null) { editIndex = null; seed = null; renderAddBar(); } // edit target moved → cancel edit + if (editIndex !== null) { cancelEdit(); renderAddBar(); } // edit target moved → cancel edit recompile(); renderSteps(); } @@ -247,7 +250,7 @@ stepsEl.querySelectorAll('.wcb-step-remove').forEach(btn => btn.addEventListener('click', () => { const i = parseInt(btn.dataset.i, 10); steps.splice(i, 1); - if (editIndex === i) { editIndex = null; seed = null; renderAddBar(); } + if (editIndex === i) { cancelEdit(); renderAddBar(); } else if (editIndex !== null && editIndex > i) editIndex -= 1; recompile(); renderSteps(); })); @@ -322,7 +325,7 @@ step.label = editLabel; } steps[editIndex] = step; - editIndex = null; editLabel = undefined; editWasAuto = false; seed = null; + cancelEdit(); } else { // a fresh insert gets the command's default comment label, rendered from the selection const auto = E().renderCommentLabel(cmd, params); @@ -376,13 +379,28 @@ addbarEl.querySelector('.wcb-insert').addEventListener('click', insertOrUpdate); const cancelBtn = addbarEl.querySelector('.wcb-cancel'); if (cancelBtn) cancelBtn.addEventListener('click', () => { - editIndex = null; editLabel = undefined; editWasAuto = false; seed = null; renderAddBar(); renderSteps(); + cancelEdit(); renderAddBar(); renderSteps(); }); fillCommands(true); // honor the edit pre-fill on first paint } + // For a host that also edits the value as text: take its new value without + // calling onChange. Equal to what the steps compile to: nothing happens. Otherwise + // the steps are re-parsed, a step being edited in the add bar is cancelled (as a + // reorder does), and the step list, length and safety note re-render; a half-filled + // add bar is kept. + function setValue(next) { + const value = next == null ? '' : String(next); + if (value === compiled()) return; + steps = value ? E().parseWCBValue(value) : []; + dragFrom = null; + if (editIndex !== null) { cancelEdit(); renderAddBar(); } + renderSteps(); + } + renderSteps(); renderAddBar(); + return { setValue }; } return { renderComposer, stepLabel, humanize, captionFor, groupCommandsForDropdown, paramControlHtml }; diff --git a/src/droidnet-command-library.js b/src/droidnet-command-library.js index 47e0c53..bb6e1bd 100644 --- a/src/droidnet-command-library.js +++ b/src/droidnet-command-library.js @@ -351,6 +351,9 @@ function buildWCBValue(steps) { const out = []; for (const step of steps) { + // A note after another step sits on its own line ('^^***'), unless it was parsed + // from a single '^' (step.inline). A note first in the value stays '***text'. + if (step.type === 'comment' && !step.inline && out.length > 0) out.push(''); out.push(_stepToken(step)); if ((step.type === 'command' || step.type === 'raw') && step.label !== undefined) { out.push('***' + step.label); @@ -362,15 +365,22 @@ function parseWCBValue(value) { const steps = []; const frags = String(value).split('^'); + let afterEmpty = false; 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; + // skips it too, so it becomes no step. Rebuilding drops it (firmware-equivalent), + // except the one in '^^***', which keeps the comment after it on its own line. + if (frag === '') { afterEmpty = true; continue; } + const ownLine = afterEmpty; + afterEmpty = false; if (frag.startsWith('***')) { const text = frag.slice(3); const prev = steps[steps.length - 1]; - if (prev && (prev.type === 'command' || prev.type === 'raw') && prev.label === undefined) { + if (!ownLine && prev && (prev.type === 'command' || prev.type === 'raw') && prev.label === undefined) { prev.label = text; + } else if (!ownLine && prev) { + // After a delay, a labelled step or another note: written back with one '^'. + steps.push({ type: 'comment', text, inline: true }); } else { steps.push({ type: 'comment', text }); } diff --git a/test/comment-steps.test.js b/test/comment-steps.test.js new file mode 100644 index 0000000..344075f --- /dev/null +++ b/test/comment-steps.test.js @@ -0,0 +1,96 @@ +'use strict'; +// 4.6.0: a comment keeps its place. DroidNet's stored-command field writes a comment on +// its own line as '^^***' and an inline one as '^***'; the builder must rebuild both +// byte for byte, so a builder action changes only the step it touches. +function loadEngine() { + jest.resetModules(); + return require('../src/droidnet-command-library.js'); +} + +const LIB = { + enums: { mode: { values: [{ code: '3', label: 'Alarm' }, { code: '92', label: 'VU' }] } }, + components: [{ + id: 'test', name: 'Test', kind: 'device-native', + routing: { class: 'broadcast', nativeWrapper: 'none' }, + commands: [ + { id: 't.mode', name: 'Mode', template: '4T{mode}', + params: [{ name: 'mode', enum: 'mode' }], examples: ['4T3'] }, + ], + }], +}; + +describe('comment steps keep their place (4.6.0)', () => { + let cb; + beforeEach(() => { + cb = loadEngine(); + cb.loadLibrary(JSON.parse(JSON.stringify(LIB)), { libraryVersion: 'test' }); + }); + + test('a comment on its own line (^^***) is a standalone note, not the label', () => { + const steps = cb.parseWCBValue('4T3^^*** own line'); + expect(steps).toHaveLength(2); + expect(steps[0]).toMatchObject({ type: 'command', commandId: 't.mode' }); + expect(steps[0].label).toBeUndefined(); + expect(steps[1]).toEqual({ type: 'comment', text: ' own line' }); + }); + + test('a comment directly after a command (^***) is still its label', () => { + const steps = cb.parseWCBValue('4T3^*** inline'); + expect(steps).toHaveLength(1); + expect(steps[0]).toMatchObject({ type: 'command', label: ' inline' }); + }); + + test('a comment after a delay with a single ^ is an inline note step', () => { + expect(cb.parseWCBValue(';t500^*** wait')[1]).toEqual({ type: 'comment', text: ' wait', inline: true }); + }); + + test('a note step after another step is written on its own line', () => { + expect(cb.buildWCBValue([ + { type: 'command', commandId: 't.mode', params: { mode: '3' } }, + { type: 'comment', text: ' note' }, + ])).toBe('4T3^^*** note'); + }); + + test('a note step first in the value stays ***text', () => { + expect(cb.buildWCBValue([ + { type: 'comment', text: ' note' }, + { type: 'command', commandId: 't.mode', params: { mode: '3' } }, + ])).toBe('*** note^4T3'); + }); + + test.each([ + ['an inline comment', '4T3^*** note'], + ['a comment on its own line', '4T3^^*** note'], + ['a comment as the first line', '*** note^4T3'], + ['two comment lines in a row', '4T3^^*** a^^*** b'], + ['a comment on its own line after a delay', '4T3^;t500^^*** wait'], + ['an inline comment after a delay', '4T3^;t500^*** wait'], + ['two inline comments on one line', '4T3^*** a^*** b'], + ['two comment lines first', '*** a^^*** b^4T92'], + ['an inline comment after a first-line comment', '*** a^*** b'], + ['a labelled raw step, then a comment line', '^*** r^^*** own'], + ['a comment line between commands', '4T3^^*** own^4T92'], + ])('%s rebuilds byte for byte: %s', (_name, value) => { + expect(cb.buildWCBValue(cb.parseWCBValue(value))).toBe(value); + }); + + test('a note added in the builder after a command rebuilds byte for byte', () => { + const steps = cb.parseWCBValue('4T3'); + steps.push({ type: 'comment', text: ' note' }); + const value = cb.buildWCBValue(steps); + expect(value).toBe('4T3^^*** note'); + expect(cb.buildWCBValue(cb.parseWCBValue(value))).toBe(value); + }); + + test('the WCB runs the same commands: only empty segments and comments move', () => { + const run = (v) => v.split('^').filter((s) => s !== '' && !s.startsWith('***')); + for (const v of ['4T3^^*** a', '*** a^4T3', '4T3^*** a^^*** b^4T92', '^4T3^^4T92^']) { + expect(run(cb.buildWCBValue(cb.parseWCBValue(v)))).toEqual(run(v)); + } + }); + + test('other empty segments still become no step', () => { + expect(cb.buildWCBValue(cb.parseWCBValue('^4T3^^4T92^'))).toBe('4T3^4T92'); + expect(cb.buildWCBValue(cb.parseWCBValue('4T3^^^*** a'))).toBe('4T3^^*** a'); + }); +}); diff --git a/test/engine.test.js b/test/engine.test.js index 51a4570..5964811 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.5.0'); + expect(cb.getLibraryVersion()).toBe('4.6.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 fe52b66..a06b931 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.5.0'); + expect(manifest.libraryVersion).toBe('4.6.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.5.0'); + expect(lib.libraryVersion).toBe('4.6.0'); expect(lib.components.length).toBe(24); }); test('loadCatalog() loads the engine and resolves commands', () => { const engine = loadCatalog(); - expect(engine.getLibraryVersion()).toBe('4.5.0'); + expect(engine.getLibraryVersion()).toBe('4.6.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/ui-edit-cancel.test.js b/test/ui-edit-cancel.test.js new file mode 100644 index 0000000..a577d93 --- /dev/null +++ b/test/ui-edit-cancel.test.js @@ -0,0 +1,91 @@ +/** + * @jest-environment jsdom + */ +'use strict'; +// Every way out of "editing a step in the Add bar" leaves the Add bar in plain Add +// mode, with no step highlighted, so the next Insert appends instead of replacing. +// The five exits (Cancel, Update, removing the edited step, reordering, and setValue) +// share one reset; setValue's exit is also covered in ui-set-value.test.js. +const LIB = { + enums: { mode: { values: [{ code: '3', label: 'Alarm' }, { code: '92', label: 'VU' }] } }, + components: [{ + id: 'test', name: 'Test', kind: 'device-native', + routing: { class: 'broadcast', nativeWrapper: 'none' }, + commands: [ + { id: 't.mode', name: 'Mode', template: '4T{mode}', + params: [{ name: 'mode', enum: 'mode' }], examples: ['4T3'] }, + ], + }], +}; + +function loadUI() { + jest.resetModules(); + const engine = require('../src/droidnet-command-library.js'); + const UI = require('../src/droidnet-command-library-ui.js'); + engine.loadLibrary(JSON.parse(JSON.stringify(LIB)), { libraryVersion: 'test' }); + return UI; +} + +describe('leaving edit mode in the Add bar', () => { + let UI; + let host; + let changes; + beforeEach(() => { + UI = loadUI(); + document.body.innerHTML = '
'; + host = document.getElementById('host'); + changes = []; + }); + const render = (value) => UI.renderComposer(host, value, { onChange: (v) => changes.push(v) }); + const label = () => host.querySelector('.add-lbl').textContent; + const editFirst = () => { + host.querySelector('.wcb-step-edit').click(); + expect(label()).toBe('Edit:'); + expect(host.querySelector('.wcb-step.editing')).not.toBeNull(); + }; + const expectAddMode = () => { + expect(label()).toBe('Add:'); + expect(host.querySelector('.wcb-cancel')).toBeNull(); + expect(host.querySelector('.wcb-step.editing')).toBeNull(); + }; + const lastChange = () => changes[changes.length - 1]; + + test('Cancel returns to Add mode and the next Insert appends', () => { + render('4T3^4T92'); + editFirst(); + host.querySelector('.wcb-cancel').click(); + expectAddMode(); + expect(changes).toEqual([]); + host.querySelector('.wcb-insert').click(); + expect(lastChange().startsWith('4T3^4T92^4T')).toBe(true); + }); + + test('Update writes the edited step and returns to Add mode', () => { + render('4T3^4T92'); + editFirst(); + host.querySelector('.wcb-addbar .wcb-param').value = '92'; + host.querySelector('.wcb-insert').click(); + expectAddMode(); + expect(lastChange().startsWith('4T92^4T92')).toBe(true); + host.querySelector('.wcb-insert').click(); + expect(lastChange().split('^').filter((t) => t.startsWith('4T'))).toHaveLength(3); + }); + + test('removing the step being edited returns to Add mode', () => { + render('4T3^4T92'); + editFirst(); + host.querySelectorAll('.wcb-step-remove')[0].click(); + expectAddMode(); + expect(changes).toEqual(['4T92']); + }); + + test('reordering while editing returns to Add mode', () => { + render('4T3^4T92'); + editFirst(); + const steps = host.querySelectorAll('.wcb-step'); + steps[0].dispatchEvent(new window.Event('dragstart')); + steps[1].dispatchEvent(new window.Event('drop')); + expectAddMode(); + expect(changes).toEqual(['4T92^4T3']); + }); +}); diff --git a/test/ui-set-value.test.js b/test/ui-set-value.test.js new file mode 100644 index 0000000..7a72e07 --- /dev/null +++ b/test/ui-set-value.test.js @@ -0,0 +1,132 @@ +/** + * @jest-environment jsdom + */ +'use strict'; +// 4.6.0: renderComposer returns { setValue(value) }, so a host that also edits the value +// as text (DroidNet's stored-command field) can hand the builder each change. +const LIB = { + enums: { mode: { values: [{ code: '3', label: 'Alarm' }, { code: '92', label: 'VU' }] } }, + components: [{ + id: 'test', name: 'Test', kind: 'device-native', + routing: { class: 'broadcast', nativeWrapper: 'none' }, + commands: [ + { id: 't.mode', name: 'Mode', template: '4T{mode}', + params: [{ name: 'mode', enum: 'mode' }], examples: ['4T3'] }, + { id: 't.move', name: 'Move', template: 'MV{mode}', safety: 'movement', + params: [{ name: 'mode', enum: 'mode' }], examples: ['MV3'] }, + ], + }], +}; + +function loadUI() { + jest.resetModules(); + const engine = require('../src/droidnet-command-library.js'); + const UI = require('../src/droidnet-command-library-ui.js'); + engine.loadLibrary(JSON.parse(JSON.stringify(LIB)), { libraryVersion: 'test' }); + return UI; +} + +describe('renderComposer handle', () => { + let UI; + let host; + let changes; + beforeEach(() => { + UI = loadUI(); + document.body.innerHTML = '
'; + host = document.getElementById('host'); + changes = []; + }); + const render = (value) => UI.renderComposer(host, value, { onChange: (v) => changes.push(v) }); + const tokens = () => [...host.querySelectorAll('.wcb-step-token')].map((e) => e.textContent); + + test('returns a handle with setValue', () => { + expect(typeof render('4T3').setValue).toBe('function'); + }); + + test('setValue with the compiled value changes nothing', () => { + const handle = render('4T3^*** note'); + const step = host.querySelector('.wcb-step'); + handle.setValue('4T3^*** note'); + expect(host.querySelector('.wcb-step')).toBe(step); + expect(changes).toEqual([]); + }); + + test('setValue re-renders the steps, the length and the safety note without onChange', () => { + const handle = render('4T3'); + expect(host.querySelector('.wcb-safety').textContent).toBe(''); + handle.setValue('4T3^MV92'); + expect(tokens()).toEqual(['4T3', 'MV92']); + expect(host.querySelector('.wcb-len').textContent).toBe('8 / 200'); + expect(host.querySelector('.wcb-safety').textContent).toMatch(/movement/); + expect(changes).toEqual([]); + }); + + test('an empty or null value shows the empty state', () => { + const handle = render('4T3'); + handle.setValue(''); + expect(host.querySelectorAll('.wcb-step')).toHaveLength(0); + expect(host.querySelector('.wcb-empty')).not.toBeNull(); + expect(host.querySelector('.wcb-len').textContent).toBe('0 / 200'); + handle.setValue('4T3'); + handle.setValue(null); + expect(host.querySelectorAll('.wcb-step')).toHaveLength(0); + }); + + test('a half-filled Add bar is kept', () => { + const handle = render('4T3'); + const cmdSel = host.querySelector('.wcb-cmd'); + cmdSel.value = 't.move'; + cmdSel.dispatchEvent(new window.Event('change')); + const param = host.querySelector('.wcb-addbar .wcb-param'); + param.value = '92'; + handle.setValue('4T92'); + expect(host.querySelector('.wcb-addbar .wcb-param')).toBe(param); + expect(param.value).toBe('92'); + expect(host.querySelector('.wcb-cmd').value).toBe('t.move'); + }); + + test('a step being edited in the Add bar is cancelled', () => { + const handle = render('4T3^4T92'); + host.querySelector('.wcb-step-edit').click(); + expect(host.querySelector('.add-lbl').textContent).toBe('Edit:'); + handle.setValue('4T92'); + expect(host.querySelector('.add-lbl').textContent).toBe('Add:'); + expect(host.querySelector('.wcb-cancel')).toBeNull(); + expect(host.querySelector('.wcb-step.editing')).toBeNull(); + expect(changes).toEqual([]); + }); + + // A host (DroidNet's stored-command field) hands setValue board-read text the builder + // spells differently: '4T3^^4T92' rebuilds as '4T3^4T92'. Such a value is never equal + // to compiled(), so every call takes the re-render path. The host relies on that, and + // on onChange staying silent, so both are pinned here. + test('a value the steps rebuild differently re-renders on every call, without onChange', () => { + const handle = render('4T3'); + handle.setValue('4T3^^4T92'); + expect(tokens()).toEqual(['4T3', '4T92']); + expect(host.querySelector('.wcb-len').textContent).toBe('8 / 200'); // the rebuilt 4T3^4T92, not the typed 9 + const step = host.querySelector('.wcb-step'); + handle.setValue('4T3^^4T92'); + expect(host.querySelector('.wcb-step')).not.toBe(step); + expect(tokens()).toEqual(['4T3', '4T92']); + expect(changes).toEqual([]); + }); + + test('a value the steps rebuild differently cancels a step being edited', () => { + const handle = render('4T3^4T92'); + host.querySelector('.wcb-step-edit').click(); + expect(host.querySelector('.add-lbl').textContent).toBe('Edit:'); + handle.setValue('4T3^^4T92'); + expect(host.querySelector('.add-lbl').textContent).toBe('Add:'); + expect(host.querySelector('.wcb-cancel')).toBeNull(); + expect(host.querySelector('.wcb-step.editing')).toBeNull(); + expect(changes).toEqual([]); + }); + + test('builder actions after setValue work on the new steps', () => { + const handle = render('4T3'); + handle.setValue('4T3^4T92'); + host.querySelectorAll('.wcb-step-remove')[0].click(); + expect(changes).toEqual(['4T92']); + }); +});