diff --git a/docs/api.md b/docs/api.md index 40eb21d..25ac96a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -720,8 +720,10 @@ callers. **Action steps** (`type: "action"`): `kind` is `envelope` (`event`, `data`, optional `src` - the device-identities source override), -`event` (`event` name or hex + `fields`, optional `src` - the semantic event -download for 0x03/0x0B/0x12/0x1D), `menu-select` (`item_id` 1-255, or `text` + +`event` (`event` name or hex + `fields` for the four server-built events +0x03/0x0B/0x12/0x1D, or the PWA form's built `data` hex for any other event - +validated as hex and against the one-ENVELOPE budget at load; optional `src`, +the device-identities override), `menu-select` (`item_id` 1-255, or `text` + `mode` `exact`/`contains` (case-insensitive by default; `case_sensitive: true` forces an exact-case match) - the text is resolved against the card's cached SET UP MENU, which is refreshed whenever the card sends the menu), `file-write` diff --git a/docs/test-scripts.md b/docs/test-scripts.md index 68463f0..4275848 100644 --- a/docs/test-scripts.md +++ b/docs/test-scripts.md @@ -259,7 +259,7 @@ consecutive runs continue correctly. | kind | params | notes | |---|---|---| | `envelope` | `event` 0–255, `data` hex (optional), optional `src` | ENVELOPE(Event Download) with raw data; `src` overrides the device-identities source (`82` terminal / `83` network) | -| `event` | `event` (name or hex), `fields` (object), optional `src` | semantic ENVELOPE(Event Download) for 0x03/0x0B/0x12/0x1D (see below) | +| `event` | `event` (name or hex), `fields` (object) or `data` (form-built hex), optional `src` | semantic ENVELOPE(Event Download): the four server-built events from `fields`, every other event from the editor's `data` (see below) | | `menu-select` | `item_id` 1–255 **or** `text` + `mode` `exact`/`contains` (+ `case_sensitive: true` for an exact-case match — the default is case-insensitive; the step editor's **Match case** choice writes it), not both | ENVELOPE(Menu Selection); the text is resolved against the card's cached menu - refreshed whenever the card sends SET UP MENU, so it follows the applet's install parameters | | `file-write` | `path`, `data`, `mode` `auto`/`binary`/`record`, `record` 1–255 | UPDATE BINARY/RECORD | | `file-read` | `path`, `mode`, `record` | READ BINARY/RECORD; verify with `check.data` | @@ -278,10 +278,17 @@ a remote entity through OTA/RFM, TS 102 223 §7.5.9): assert the change with ### Semantic events (`kind: "event"`) -The server builds the event objects for the events a test needs most; an -event without a builder goes through the raw `envelope` action, whose data hex -the Phone tab's event form shows (**Event data (hex)**, copyable). Both -actions take the same optional `src` device-identities override. +The step editor renders the **same form the Phone tab's send dialog uses** +(`EVENT_FORMS`), for every modelled event - no release gating (a script may +target another card). The four events the server builds (`0x03`/`0x0B`/`0x12`/ +`0x1D`) travel as `fields`: the server validates them at script load and builds +the objects at run time (so the data-connection host clock is fresh). Every +other event carries the form's built hex as `data` (the editor shows it live +under the form, with the one-ENVELOPE check; the engine validates it and the +single-envelope budget at load). The `src` device-identities source comes from +the event itself (e.g. MT call and IMS registration are network-sourced) or +from the form's source field; an explicit `src` overrides it. Both the +`event` and the raw `envelope` action take the same optional `src`. ```json {"type": "action", "kind": "event", diff --git a/frontend/help-ru.html b/frontend/help-ru.html index f8b9e18..7d0a9fb 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -564,10 +564,10 @@

Тест-скрипт — упорядоченный список действий и ожиданий проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия Очистить отчёт. Скрипты хранятся в хранилище на сервере (~/.pysim-simple-server/test_scripts.json, --test-scripts), поэтому все браузеры работают с одним набором; изменения записываются в хранилище по мере правки (кнопки «Сохранить» нет — черновик без валидного шага остаётся локальным); экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).

У вкладки четыре представления: список наборов (по умолчанию; Добавить набор / Редактировать / Запустить / Экспорт / Удалить), редактор набора (скрипты набора в порядке выполнения — необязательный setup первым и teardown последним, с пометками ролей; у каждого скрипта Запустить/Редактировать/Переместить/Скопировать/Удалить, политика при ошибке остановить (по умолчанию) / продолжить и Требуется ADM), редактор скрипта (список шагов; кнопка Старт выполняет только этот скрипт и показывает результат под шагами — он не сохраняется) и отчёт набора (после запуска набора: статус каждого скрипта, таблица шагов с ожидаемым/фактическим, примечание об ошибке и выдержка из журнала запуска; сводка с успешно/предупреждения/ошибки/пропущено, время выполнения, счётчики SCP80 до → после по каждому keyset, пресет и предупреждение о смене сессии карты; экспорт в JSON или Markdown). Запуск набора выполняет его скрипты по порядку в одной сессии карты, без сбросов и без продолжения с места остановки; при ошибке решает, остановится ли набор на этом скрипте или запустится следующий, teardown выполняется всегда при остановке или ошибке (пока карта в ридере), чтобы следующий тест начался с чистого состояния, а сброс карты останавливает набор и отмечается в отчёте. Требуется ADM (у набора или скрипта) проверяет ключ ADM из подобранного пресета один раз перед запуском — ошибка отклоняет запуск (проверьте вручную через значок ADM; автоматических повторов нет). Скрипты хранятся в хранилище наборов (~/.pysim-simple-server/test_suites.json, --test-suites); каждый скрипт принадлежит ровно одному набору, и скрипт можно переместить или скопировать в другой набор (экспорт набора — это пакет с набором и его скриптами; старый экспорт только скриптов импортируется в автоматически созданный набор «Imported scripts»).

Каждый шаг пишет семантическую строку в журнал сервера под своим номером (TEST-RUN step N: …), запрос перед ответом: отправленный APDU/данные и полученный SW, для SCP80 — открытый C-APDU, собранный защищённый пакет и R-APDU из PoR, для ожидания — тройка FETCH / команда / TERMINAL RESPONSE, очистка proactive — каждый раунд STATUS/FETCH/команда/TR, а очистка в конце запуска — поглощённые команды (отвечаются ok — cancel отправляется только по явному запросу скрипта), а для выбора пункта меню — полный ENVELOPE с определённым пунктом. Запустите сервер с --apdu-trace, чтобы добавить и журнал сырых транспортных APDU.

diff --git a/frontend/help.html b/frontend/help.html index 6ed23e3..809656c 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -563,10 +563,10 @@

A test script is an ordered list of actions and proactive-command expectations executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP 409) and background STATUS polling is paused; the run report is kept in memory (also after reloading the page) until Clear report. Scripts live in the server-side store (~/.pysim-simple-server/test_scripts.json, --test-scripts), so every browser manages the same set; edits are written to the store as you change them (there is no Save button — a draft without a valid step stays local until it is valid); JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).

The pill has four views: the suite list (default; Add suite / Edit / Run / Export / Delete), the suite editor (the suite's scripts in run order — the optional setup first and the teardown last, marked with role badges; per script Run/Edit/Move/Copy/Delete, the per-member on failure policy stop (default) / continue, and ADM required), the script editor (the step list; its Start runs that single script and shows the result under the steps — not preserved) and the suite report (after a suite run: every script's status, its step table with expected/actual, the failing note and a run-log excerpt; the summary with passed/warnings/failed/skipped, wall time, the SCP80 counters before → after per keyset, the preset and a card-session-changed warning; export as JSON or Markdown). A suite run executes its scripts in order in one card session, without resets and without resume; on failure decides whether the suite stops at that script or runs the next one, the teardown always runs on a stop or a failure (while the card is in the reader) so the next test starts clean, and a card reset stops the suite and is flagged in the report. ADM required (on the suite or a script) verifies the matched preset's ADM key once before the start — a failure refuses the run (verify manually with the ADM badge; nothing is retried automatically). Scripts live in the suite's store (~/.pysim-simple-server/test_suites.json, --test-suites); every script belongs to exactly one suite, and a script can be moved or copied to another suite (a bundle export carries the suite plus its scripts; the old script-only export imports into an auto-created “Imported scripts” suite).

Every step logs a semantic line to the server log under its step number (TEST-RUN step N: …), request before response: the APDU/data sent and the SW received, for SCP80 the plaintext C-APDU, the built secured packet and the PoR's R-APDU, for an expectation the FETCH / command / TERMINAL RESPONSE trio, a proactive cleanup every STATUS/FETCH/command/TR round and the run cleanup the commands it drained (answered ok — a cancel is only sent when a script asks for it), and for a menu selection the complete ENVELOPE with the resolved item. Start the server with --apdu-trace to add the raw transport APDU log as well.

diff --git a/frontend/index.html b/frontend/index.html index 928d0d3..4588a23 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7123,6 +7123,9 @@ function testScriptProblem(script) { || p.fields === null || Array.isArray(p.fields))) { return at + t('event fields must be an object'); } + const data = String(p.data || '').replace(/\s/g, ''); + if (!p.fields && !data) return at + t('event data is required'); + if (data && !/^[0-9A-Fa-f]+$/.test(data)) return at + t('event data must be hex'); } if ((s.kind === 'file-write' || s.kind === 'file-read') && !String(p.path || '').trim()) return at + t('file path is empty'); if (s.kind === 'proactive' && String(p.attempts === undefined ? '' : p.attempts).trim() !== '' @@ -7196,6 +7199,7 @@ function testStepSummary(step) { else if (step.kind === 'event') { const code = parseInt(p.event, 16); s = 'EVENT ' + (EVENT_NAMES[code] || ('0x' + String(p.event || '?'))); + if (p.src) s += ' src=' + p.src; } else if (step.kind === 'apdu') s = 'APDU ' + (p.apdu || '?'); else if (step.kind === 'scp80') s = 'SCP80 ' + ((p.source || (p.sp ? 'sp' : 'apdu')) === 'sp' ? (p.sp || '(secured packet)') : (p.apdu || '?')) + (p.tar ? ' TAR=' + p.tar : '') + (p.spi1 ? ' SPI1=' + p.spi1 : '') + (p.spi2 ? ' SPI2=' + p.spi2 : '') + (p.format && p.format !== 'compact' ? ' fmt=' + p.format : ''); @@ -7794,18 +7798,42 @@ function testStepRender() { html += testFormRow('Event data (hex, optional)', testFormInput('test-f-data', p.data, '9B0100')); html += testFormRow('Source override (hex, optional)', testFormInput('test-f-eventsrc', p.src, '82 terminal / 83 network')); } else if (step.kind === 'event') { - // The semantic event download: the four events the server builds - // (others go through the raw envelope action with the hex the - // Phone form shows). - html += testFormRow('Event', testFormSelect('test-f-evtype', p.event || '03', - [0x03, 0x0B, 0x12, 0x1D].map(c => ({ - v: c.toString(16).toUpperCase().padStart(2, '0'), - l: EVENT_NAMES[c]})))); - html += testFormRow('Event fields (JSON)', ''); + // The semantic event download: the same forms the Simulator uses + // (EVENT_FORMS), for every modelled event - the server builds the + // four it models from `fields`, the editor's built hex (`data`) + // covers the rest. No release gating: a script may target another + // card (the Phone tab's form gates against the equipped one). + // Object.keys(EVENT_FORMS) yields the numeric keys as decimal + // strings (the table is written with 0x… literals); the select's + // option values are the hex byte forms. + const codes = Object.keys(EVENT_FORMS).map(k => parseInt(k, 10)) + .sort((a, b) => a - b); + const rawEvent = p.event === undefined ? '03' : p.event; + let cur = (typeof rawEvent === 'number') ? rawEvent : parseInt(String(rawEvent), 16); + if (!(Number.isInteger(cur) && cur >= 0 && cur <= 0xFF)) cur = 3; + const opts = codes.map(c => ({ + v: c.toString(16).toUpperCase().padStart(2, '0'), + l: EVENT_NAMES[c] || ('0x' + c.toString(16).toUpperCase())})); + if (codes.indexOf(cur) < 0) { + opts.push({v: cur.toString(16).toUpperCase().padStart(2, '0'), + l: t('unknown') + ' 0x' + cur.toString(16).toUpperCase()}); + } + html += testFormRow('Event', testFormSelect('test-f-evtype', + cur.toString(16).toUpperCase().padStart(2, '0'), opts, + 'testEventTypeChanged()')); + const cfg = EVENT_FORMS[cur]; + if (cfg) { + html += '
' + + eventFormFieldsHtml(cfg, cfg.fields, p.fields || {}, 'test-f-ev-', false) + + '
'; + html += testFormRow('Event data (hex)', '' + + ''); + } else { + html += '

' + + esc(t('No form for this event - use the raw Envelope action.')) + '

'; + } } else if (step.kind === 'menu-select') { html += testFormRow('Item id (1-255)', testFormInput('test-f-item', p.item_id === undefined ? '' : p.item_id, '1..255')); html += testFormRow('\u2026 or item text', @@ -7910,6 +7938,10 @@ function testStepRender() { {v: 'error', l: t('Error (stop the script)')}, {v: 'warning', l: t('Warning (continue)')}])); } body.innerHTML = html; + if (isAction && step.kind === 'event' && document.getElementById('test-f-ev-form')) { + testEventGroups(); + testEventPreview(); + } if (!isAction) testRenderChecks(); } @@ -7938,6 +7970,62 @@ function testStepKindChanged() { testStepRender(); } +// The step editor's event form (the Simulator's EVENT_FORMS): the select +// rebuilds the form (the previous event's values do not apply), the generic +// group updater hides the fields a group excludes, and the preview shows the +// exact event-data hex the step will send. +function testEventTypeChanged() { + testStepCollect(); + if (!_testEditStep || _testEditStep.type !== 'action') return; + const code = parseInt((document.getElementById('test-f-evtype') || {}).value || '03', 16); + _testEditStep.params = {event: code.toString(16).toUpperCase().padStart(2, '0')}; + testStepRender(); +} + +function testEventGroups() { + const box = document.getElementById('test-f-ev-form'); + if (!box) return; + const code = parseInt((document.getElementById('test-f-evtype') || {}).value || '03', 16); + const cfg = EVENT_FORMS[code]; + const groups = {}; + ((cfg && cfg.fields) || []).forEach(f => + String(f.g || '').split(',').filter(Boolean).forEach(x => { groups[x] = true; })); + let g = ''; + ((cfg && cfg.fields) || []).forEach(f => { + if (g || f.type !== 'select') return; + const sel = document.getElementById('test-f-ev-' + f.id); + if (!sel) return; + const opt = sel.options[sel.selectedIndex]; + const og = opt && opt.getAttribute ? opt.getAttribute('data-gr') : ''; + if (og) { g = og; return; } + if (sel.value && groups[sel.value]) g = sel.value; + }); + eventFieldGroupsUpdate(box, g); +} + +function testEventPreview() { + const el = document.getElementById('test-f-evdata'); + if (!el) return; + const code = parseInt((document.getElementById('test-f-evtype') || {}).value || '03', 16); + const cfg = EVENT_FORMS[code]; + let hex = ''; + if (cfg) { + try { hex = cfg.build(eventFormValues(cfg, null, 'test-f-ev-')) || ''; } catch (e) { hex = ''; } + } + el.value = hex; + const warn = document.getElementById('test-f-evwarn'); + if (warn) { + if (hex && !eventFitsOneEnvelope(hex)) { + warn.textContent = t('The event data does not fit one ENVELOPE') + ' (' + + eventEnvelopeSize(hex) + ' > ' + EVENT_INNER_MAX + ' B). ' + + t('Multi-envelope (chained) delivery is not implemented - reduce the slice lists.'); + warn.classList.remove('hidden'); + } else { + warn.classList.add('hidden'); + } + } +} + function testRenderChecks() { const el = document.getElementById('test-checks'); if (!el) return; @@ -8077,19 +8165,20 @@ function testStepCollect() { } } else if (kind === 'event') { - p.event = val('test-f-evtype') || '03'; - const raw = val('test-f-evfields'); - p.fields = {}; - // the flag reflects THIS collect, so fixing the JSON saves again - delete step._eventFieldsError; - if (raw) { - try { - const obj = JSON.parse(raw); - if (obj && typeof obj === 'object' && !Array.isArray(obj)) p.fields = obj; - else step._eventFieldsError = true; - } catch (e) { - step._eventFieldsError = true; - } + const evCode = parseInt(val('test-f-evtype') || '03', 16); + p.event = (Number.isInteger(evCode) && evCode >= 0 && evCode <= 0xFF ? evCode : 3) + .toString(16).toUpperCase().padStart(2, '0'); + const cfg = EVENT_FORMS[parseInt(p.event, 16)]; + if (cfg) { + // The form values (the server builds the four events it models + // from them) and the built hex the other events run with. + const values = eventFormValues(cfg, null, 'test-f-ev-'); + p.fields = values; + let hex = ''; + try { hex = cfg.build(values) || ''; } catch (e) { hex = ''; } + p.data = hex; + const src = eventFormSrc(cfg, values); + if (src) p.src = src; } } else if (kind === 'file-write' || kind === 'file-read') { @@ -8200,8 +8289,16 @@ function testStepFormError() { const problem = testMenuSelectProblem(p); if (problem) return problem; } - if (step.kind === 'event' && step._eventFieldsError) { - return t('Event fields must be a JSON object'); + if (step.kind === 'event') { + const code = parseInt(String((step.params || {}).event || ''), 16); + const cfg = EVENT_FORMS[code]; + if (!cfg) return t('No form for this event - use the raw Envelope action.'); + const bad = eventFormPatternError(cfg, (step.params || {}).fields || {}); + if (bad) return t('Invalid') + ': ' + t(bad); + if (!String((step.params || {}).data || '')) return t('Event data is empty'); + if (!eventFitsOneEnvelope((step.params || {}).data)) { + return t('The event data does not fit one ENVELOPE'); + } } if ((step.kind === 'file-write' || step.kind === 'file-read') && !p.path) return t('file path is empty'); if (step.kind === 'status' && !(p.attempts >= 1)) return t('attempts must be at least 1'); @@ -15792,13 +15889,49 @@ function eventFitsOneEnvelope(eventDataHex) { return eventEnvelopeSize(eventDataHex) <= EVENT_INNER_MAX; } -// The visible event-form values (reads the DOM; a hidden or missing field is -// left out). Shared by the send action and the hex preview. -function pysimEventValues() { - const cfg = EVENT_FORMS[_eventFormType]; +// The event form's fields as HTML (pure): shared by the Phone tab's send form +// and the test-script step editor. `prefix` keeps the ids unique per form +// ('ev-f-' / 'test-f-ev-'), `values` prefills them (the step editor reopens a +// stored action) and `fields` limits the list (the Phone tab hides the fields +// introduced after the card's CAT release - the step editor shows all). +// `withOnchange` keeps the table's per-field onchange handlers (the Phone +// tab's updaters read its own ids; the step editor uses the generic group +// updater instead). +function eventFormFieldsHtml(cfg, fields, values, prefix, withOnchange) { + const vals = values || {}; + let html = ''; + (fields || []).forEach(f => { + const v = vals[f.id] === undefined || vals[f.id] === null ? '' : String(vals[f.id]); + html += '
'; + html += ''; + if (f.type === 'select') { + html += ''; + } else { + const attrs = []; + if (f.pattern) attrs.push('pattern="' + f.pattern + '"'); + if (f.placeholder) attrs.push('placeholder="' + f.placeholder + '"'); + if (f.min !== undefined) attrs.push('min="' + f.min + '" max="' + f.max + '"'); + if (v !== '') attrs.push('value="' + esc(v) + '"'); + html += ''; + } + html += '
'; + }); + return html; +} + +// The visible values of an event form (reads the DOM; a hidden or missing +// field is left out). `prefix` matches eventFormFieldsHtml; `fields` +// defaults to the config's list. +function eventFormValues(cfg, fields, prefix) { const values = {}; - ((cfg && cfg.fields) || []).forEach(f => { - const el = document.getElementById('ev-f-' + f.id); + (fields || (cfg && cfg.fields) || []).forEach(f => { + const el = document.getElementById(prefix + f.id); if (!el) return; const wrap = el.closest ? el.closest('.event-field') : null; if (wrap && wrap.style.display === 'none') return; @@ -15807,6 +15940,42 @@ function pysimEventValues() { return values; } +// The first field whose value violates its pattern, or '' (pure). +function eventFormPatternError(cfg, values) { + let bad = ''; + ((cfg && cfg.fields) || []).forEach(f => { + const v = values[f.id]; + if (bad || v === undefined || !f.pattern || !v) return; + if (!new RegExp('^' + f.pattern + '$').test(v)) bad = f.label; + }); + return bad; +} + +// The device-identities source of an event form (pure): the event's fixed +// value, overridden by its srcField (the call events differ near/far end). +function eventFormSrc(cfg, values) { + let src = (cfg && cfg.src) || ''; + if (cfg && cfg.srcField && values && values[cfg.srcField]) src = values[cfg.srcField]; + return src || ''; +} + +// Toggle an event form's grouped fields: a field without data-groups is +// always shown, otherwise one of its groups must equal `g`. +function eventFieldGroupsUpdate(container, g) { + if (!container) return; + container.querySelectorAll('.event-field').forEach(div => { + const groups = div.getAttribute('data-groups') || ''; + div.style.display = (groups === '' || groups.indexOf(g) >= 0) ? '' : 'none'; + }); +} + +// The visible event-form values (reads the DOM; a hidden or missing field is +// left out). Shared by the send action and the hex preview. +function pysimEventValues() { + const cfg = EVENT_FORMS[_eventFormType]; + return eventFormValues(cfg, null, 'ev-f-'); +} + // Build the current form's event data and show it - the exact bytes the send // would deliver, so a raw envelope test step can be authored by copying it. function pysimEventPreview() { @@ -15853,24 +16022,7 @@ function pysimEventSendForm(eventType) { // parameters introduced after it are hidden - the note names the source. const eff = catEffectiveReleaseInfo(cards, _pysimCardIccid); const avail = eventFieldsForRelease(cfg, eff.rel); - let html = ''; - avail.fields.forEach(f => { - const rg = f.g ? f.g.split(',') : null; - html += '
'; - html += ''; - if (f.type === 'select') { - html += ''; - } else { - const attrs = []; - if (f.pattern) attrs.push('pattern="' + f.pattern + '"'); - if (f.placeholder) attrs.push('placeholder="' + f.placeholder + '"'); - if (f.min !== undefined) attrs.push('min="' + f.min + '" max="' + f.max + '"'); - html += ''; - } - html += '
'; - }); + let html = eventFormFieldsHtml(cfg, avail.fields, {}, 'ev-f-', true); if (avail.hidden.length) { const src = eff.preset ? t('preset:') + ' ' + eff.preset.name : t('default release'); html += '

{ + const cfg = EVENT_FORMS[0x00]; + const htmlOut = eventFormFieldsHtml(cfg, cfg.fields, {ti: 'AB', ton: 1}, 'x-', false); + assert.match(htmlOut, /id="x-ti"/); + assert.match(htmlOut, /value="AB"/); + assert.match(htmlOut, /id="x-ton"/); + assert.match(htmlOut, /value="1" selected/); + // a srcField event reports its source; a fixed-source event its own + assert.strictEqual(eventFormSrc(EVENT_FORMS[0x00], {}), '83'); + assert.strictEqual(eventFormSrc(EVENT_FORMS[0x02], {src: '82'}), '82'); + assert.strictEqual(eventFormSrc(EVENT_FORMS[0x03], {}), ''); + // the pattern check names the first offending field + assert.strictEqual(eventFormPatternError(cfg, {ti: 'AB'}), ''); + assert.strictEqual(eventFormPatternError(cfg, {ti: 'ZZZ'}), 'Transaction identifier'); }); test('two quick saves of a draft create one script, then update it', async () => { @@ -508,7 +560,7 @@ test('the step form renders the chosen source and no pre-filled SW', () => { assert.match(body, /id="test-f-sw" value=""/); }); -test('the event step form renders the event select and the JSON fields', () => { +test('the event step form renders the event select and the Simulator fields', () => { const els = { 'test-step-modal': { classList: { add: () => {}, remove: () => {} } }, 'test-step-title': {}, 'test-step-body': {}, @@ -516,15 +568,23 @@ test('the event step form renders the event select and the JSON fields', () => { }; globalThis.document = { getElementById: id => els[id] || null }; _testEditStep = { type: 'action', kind: 'event', - params: { event: '12', fields: { reg_type: '9' } } }; + params: { event: '12', fields: { reg_type: '9', mcc: '250' } } }; _testEditChecks = []; _testEditStepIndex = 0; testStepRender(); const body = els['test-step-body'].innerHTML; assert.match(body, /id="test-f-evtype"/); assert.match(body, /