diff --git a/frontend/help-ru.html b/frontend/help-ru.html index e35cfb6..58c6a24 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -412,7 +412,7 @@
Работа с сессией Card Application Toolkit. Две подвкладки: «Телефон» (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и «Конфигурация TR» (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).
Если карта выдала команду SET UP MENU, вверху этого представления появляется блок «Меню STK» с изумрудной кнопкой STK: <название>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается «Меню не задано картой». Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали Timeout, сервер сам отвечает результатом timeout через --menu-timeout секунд (по умолчанию 60, 0 отключает).
Если карта выдала команду SET UP MENU, вверху этого представления появляется блок «Меню STK» с изумрудной кнопкой STK: <название>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается «Меню не задано картой». Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали Timeout, сервер сам отвечает результатом timeout через --menu-timeout секунд (по умолчанию 60, 0 отключает). Назад и Timeout продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять.
События, которые отслеживает карта. У каждого события есть кнопка Отправить, открывающая форму, специфичную для типа события:
diff --git a/frontend/help.html b/frontend/help.html index f2b86a5..4f628cc 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -412,7 +412,7 @@Interacts with the Card Application Toolkit session. The view has two pills: Phone (STK menu, STATUS and polling, subscribed events, proactive command log) and TR Config (response data injected into TERMINAL RESPONSEs for proactive commands).
When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald STK: <title> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press Timeout, the server answers with a timeout result after the --menu-timeout seconds (default 60, 0 disables).
When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald STK: <title> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press Timeout, the server answers with a timeout result after the --menu-timeout seconds (default 60, 0 disables). Back and Timeout keep the dialogue with the card going: when the card replies with a further proactive command (SELECT ITEM or DISPLAY TEXT) the panel shows it; the cached top menu appears only when the card has nothing more to execute.
The events the card monitors. Each event has a Send button that opens a form specific to the event type:
diff --git a/frontend/index.html b/frontend/index.html index 3696d42..bb2e528 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5486,9 +5486,13 @@ async function stkMenuRespond(result) { try { const data = await pysimFetch('/api/menu-respond', { result: result }); if (result === 'cancel' || result === 'back' || result === 'timeout') { - stkMenuRenderItems(); - document.getElementById('stk-menu-buttons').classList.add('hidden'); - document.getElementById('stk-back-btn').style.display = 'none'; + if (data && (data.type === 'select_item' || data.type === 'display_text')) { + stkMenuHandleResponse(data); + } else { + stkMenuRenderItems(); + document.getElementById('stk-menu-buttons').classList.add('hidden'); + document.getElementById('stk-back-btn').style.display = 'none'; + } return; } stkMenuHandleResponse(data); diff --git a/frontend/sw.js b/frontend/sw.js index 36e885c..00ab40f 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v107'; +const CACHE = 'otaman-v108'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/stk_menu.test.js b/frontend/tests/stk_menu.test.js new file mode 100644 index 0000000..f231a06 --- /dev/null +++ b/frontend/tests/stk_menu.test.js @@ -0,0 +1,85 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); + +function extractFunc(src, name, asyncFn) { + const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); + const m = re.exec(src); + if (!m) throw new Error('function ' + name + ' not found'); + let i = m.index + m[0].length - 1; + let depth = 0; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) break; + } + } + return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1); +} + +let code = extractFunc(html, 'stkMenuRespond', true) + '\n'; +code += 'globalThis.esc = s => s;\n'; +eval(code); + +function setup(response) { + const calls = { handled: null, rendered: 0 }; + globalThis.pysimFetch = async () => response; + globalThis.stkMenuHandleResponse = d => { calls.handled = d; }; + globalThis.stkMenuRenderItems = () => { calls.rendered++; }; + const btns = { classList: { add: () => {} } }; + const back = { style: {} }; + globalThis.document = { + getElementById: id => (id === 'stk-menu-buttons' ? btns : id === 'stk-back-btn' ? back : { innerHTML: '' }), + }; + return calls; +} + +test('back with a fetched SELECT ITEM continues the card dialogue', async () => { + const data = { type: 'select_item', items: [{ id: 1, text: 'Info' }] }; + const calls = setup(data); + await stkMenuRespond('back'); + assert.strictEqual(calls.handled, data); + assert.strictEqual(calls.rendered, 0); +}); + +test('back with a fetched DISPLAY TEXT shows it', async () => { + const data = { type: 'display_text', text: 'hello' }; + const calls = setup(data); + await stkMenuRespond('back'); + assert.strictEqual(calls.handled, data); + assert.strictEqual(calls.rendered, 0); +}); + +test('timeout with a fetched SELECT ITEM continues the card dialogue', async () => { + const data = { type: 'select_item', items: [] }; + const calls = setup(data); + await stkMenuRespond('timeout'); + assert.strictEqual(calls.handled, data); + assert.strictEqual(calls.rendered, 0); +}); + +test('back answered with SW 9000 falls back to the cached top menu', async () => { + const calls = setup({ type: 'done', sw: '9000' }); + await stkMenuRespond('back'); + assert.strictEqual(calls.handled, null); + assert.strictEqual(calls.rendered, 1); +}); + +test('cancel falls back to the cached top menu', async () => { + const calls = setup({ sw: '9000' }); + await stkMenuRespond('cancel'); + assert.strictEqual(calls.handled, null); + assert.strictEqual(calls.rendered, 1); +}); + +test('ok navigates with the server response', async () => { + const data = { type: 'select_item', items: [{ id: 1, text: 'x' }] }; + const calls = setup(data); + await stkMenuRespond('ok'); + assert.strictEqual(calls.handled, data); + assert.strictEqual(calls.rendered, 0); +}); diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index 6204692..195b1cd 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -1601,6 +1601,21 @@ def _menu_send_response(server, result, item_id=None): return resp, 200 +def _finish_pending_menu(server, scc): + """A new menu selection must never shadow a FETCHed command that awaits its + TERMINAL RESPONSE: answer it with a cancel TR (0x10) first, then drain any + follow-up proactive command so the card is ready for the new selection.""" + pd = server.stk_pending + if not pd: + return + sys.stderr.write('MENU-SELECT: finishing pending cmd=%02x type=%02x with cancel TR\n' + % (pd['cmd_num'], pd['cmd_type'])) + resp, _ = _menu_send_response(server, 'cancel', None) + sw = (resp or {}).get('sw', '') + if sw.startswith('91'): + _handle_proactive_chain(scc, sw) + + class PysimHandler(BaseHTTPRequestHandler): def _send_json(self, data, status=200): self.send_response(status) @@ -2179,6 +2194,7 @@ class PysimHandler(BaseHTTPRequestHandler): return body = self._read_body() self._log_req(body) + _finish_pending_menu(self.server, scc) item_id = body.get('item_id', 0) if not isinstance(item_id, int): item_id = int(item_id) diff --git a/tests/test_menu_timeout.py b/tests/test_menu_timeout.py index 5010409..be3688f 100644 --- a/tests/test_menu_timeout.py +++ b/tests/test_menu_timeout.py @@ -87,3 +87,42 @@ class TestMenuSendResponse(unittest.TestCase): if __name__ == '__main__': unittest.main() + + +class TestFinishPendingMenu(unittest.TestCase): + def make_server(self): + return types.SimpleNamespace( + stk_pending={'type': 'select_item', 'cmd_num': 1, 'cmd_type': 0x24, + 'dev_src': 0x81, 'dev_dst': 0x83, 'items': []}, + menu_active=True, scc=None) + + def make_scc(self, sent, sw='9000'): + return types.SimpleNamespace( + cat_cla='80', + _tp=types.SimpleNamespace(send_apdu=lambda h: (sent.append(h) or ('', sw)))) + + def test_no_pending_is_noop(self): + sent = [] + S._finish_pending_menu(types.SimpleNamespace(stk_pending=None), self.make_scc(sent)) + self.assertEqual(sent, []) + + def test_pending_finished_with_cancel_tr(self): + server = self.make_server() + sent = [] + scc = self.make_scc(sent) + server.scc = scc + S._finish_pending_menu(server, scc) + self.assertEqual(len(sent), 1) + tr = sent[0] + self.assertTrue(tr.startswith('801400000d'), tr) + self.assertIn('83021000', tr) # general result 0x10 = cancel + self.assertIsNone(server.stk_pending) + self.assertFalse(server.menu_active) + + def test_91xx_answer_drains_chain(self): + server = self.make_server() + scc = self.make_scc([], sw='9120') + server.scc = scc + with mock.patch.object(S, '_handle_proactive_chain') as chain: + S._finish_pending_menu(server, scc) + chain.assert_called_once_with(scc, '9120')