From 92271d672601425cfd0943c68276c90c7e973dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 12 Sep 2026 21:14:09 +0300 Subject: [PATCH] ui: probe-all-files walk and honest file tree coloring - pysimFsLoadChildren now treats {success:false,error} (and any error payload) as a failed listing: absent DFs/ADFs render as a red cross without an expand arrow instead of silently opening empty; the /api/tree 500 body also carries exists:false for shape consistency with /api/select - an expanded directory with zero children shows a gray (empty) placeholder - new Probe all files button (data-needs=card) in the tree header: walks the whole model tree from MF, verifies every DF/ADF via /api/tree and every file (incl. custom entries) via /api/select, skips subtrees of absent dirs, shows N/total progress, toggles to Stop, and reports present/absent counts with elapsed time; results colour the current tree only - tests: fs_load (error/retry/empty), fs_render (red cross, (empty)), fs_probe (flags, absent-dir skip, custom files, stop, summary); html structural check; help EN/RU, READMEs, AGENTS updated; SW cache v115 -> v116. --- README.md | 2 + README_RUS.md | 2 + frontend/help-ru.html | 1 + frontend/help.html | 1 + frontend/index.html | 105 +++++++++++++++++++++++++- frontend/sw.js | 2 +- frontend/tests/fs_load.test.js | 96 ++++++++++++++++++++++++ frontend/tests/fs_probe.test.js | 124 +++++++++++++++++++++++++++++++ frontend/tests/fs_render.test.js | 58 +++++++++++++++ frontend/tests/html.test.js | 7 ++ pysim_otaman_server/server.py | 2 +- 11 files changed, 394 insertions(+), 6 deletions(-) create mode 100644 frontend/tests/fs_load.test.js create mode 100644 frontend/tests/fs_probe.test.js create mode 100644 frontend/tests/fs_render.test.js diff --git a/README.md b/README.md index be133e0..eecc032 100644 --- a/README.md +++ b/README.md @@ -490,6 +490,8 @@ Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and - **Read** — reads the selected file (auto-detects transparent vs record files) - **Edit** — switch to edit mode, modify hex data, click **Save** to write back - **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view +- Missing files are shown in red (✗); a present but empty DF shows `(empty)` +- **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy ### Command Hints diff --git a/README_RUS.md b/README_RUS.md index 7dcf4f0..45acbf4 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -464,6 +464,8 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на - **Read** — чтение файла (автоопределение transparent/record) - **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи - **Raw / Decoded** — переключение между hex-дампом и декодированным JSON +- Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)` +- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым ### Подсказки команд diff --git a/frontend/help-ru.html b/frontend/help-ru.html index d2ca5b9..e3af178 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -322,6 +322,7 @@
  • Прочитать — чтение файла (автоопределение transparent/record)
  • Редактировать — изменение hex-данных, Сохранить для записи (или Отмена)
  • Данные как на карте / Декодированные данные — переключение между hex-дампом и декодированным JSON
  • +
  • Проверить все файлы — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают (пусто). Отображается прогресс N / всего, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.
  • 4.2 Командная строка pySim

    diff --git a/frontend/help.html b/frontend/help.html index 0b870f7..c0a12c2 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -322,6 +322,7 @@
  • Read — reads the selected file (auto-detects transparent vs record files)
  • Edit — modify hex data, Save to write back (or Cancel)
  • Raw / Decoded — toggle between hex dump and pySim-decoded JSON
  • +
  • Probe all files — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show (empty). Shows progress N / total, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.
  • 4.2 pySim command line

    diff --git a/frontend/index.html b/frontend/index.html index f2f46e4..9755398 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -860,6 +860,8 @@ FID Name + +
    @@ -5946,8 +5948,8 @@ async function pysimFsLoadChildren(node) { body = { name: node.name, fid: node.fid }; data = await pysimFetch('/api/tree', body); } - if (!data || data.exists === false) { - throw new Error('File not found'); + if (!data || data.exists === false || data.success === false || data.error) { + throw new Error((data && data.error) || 'File not found'); } node.children = (data.children || []).map(c => ({ name: c.name, @@ -5968,6 +5970,86 @@ async function pysimFsLoadChildren(node) { } } +let _pysimFsProbe = null; + +function pysimFsSelectBody(node) { + const body = { name: node.name, fid: node.fid }; + const parentSel = getParentSel(node); + if (parentSel) body.parent_sel = parentSel; + return body; +} + +function pysimFsProbeUi(text, stopLabel) { + const status = document.getElementById('pysim-fs-probe-status'); + if (status) { + status.textContent = text; + status.classList.toggle('hidden', !text); + } + const btn = document.getElementById('pysim-fs-probe-btn'); + if (btn) { + btn.textContent = stopLabel ? t('Stop') : t('Probe all files'); + btn.setAttribute('data-l10n', stopLabel ? 'Stop' : 'Probe all files'); + } +} + +async function pysimFsProbeAll() { + if (_pysimFsProbe) { _pysimFsProbe.stop = true; return; } + if (!pysimFsTreeRoot) return; + const probe = { stop: false, dirs: 0, done: 0, total: 0, present: 0, absent: 0 }; + _pysimFsProbe = probe; + const started = Date.now(); + let error = null; + const count = exists => { if (exists) probe.present++; else probe.absent++; }; + try { + if (!pysimFsTreeRoot.children) await pysimFsLoadChildren(pysimFsTreeRoot); + const discover = async node => { + for (const child of pysimFsSortChildren(node.children, pysimFsSort)) { + if (probe.stop) return; + if (!child.isDir) continue; + await pysimFsLoadChildren(child); + const exists = child.exists !== false; + probe.dirs++; + count(exists); + pysimFsRenderTree(); + pysimFsProbeUi(t('Probing:') + ' ' + t('discovering') + ' (' + probe.dirs + ')', true); + if (exists) await discover(child); + } + }; + await discover(pysimFsTreeRoot); + const files = []; + const collect = node => { + for (const child of (node.children || [])) { + if (child.isDir) { if (child.exists !== false) collect(child); } + else files.push(child); + } + }; + collect(pysimFsTreeRoot); + probe.total = files.length + probe.dirs; + probe.done = probe.dirs; + for (const file of files) { + if (probe.stop) break; + const data = await pysimFetch('/api/select', pysimFsSelectBody(file)); + const exists = !!(data && data.exists !== false && !data.error); + file.exists = exists; + if (!exists) file.children = []; + count(exists); + probe.done++; + pysimFsProbeUi(t('Probing:') + ' ' + probe.done + '/' + probe.total, true); + if (probe.done % 10 === 0 || probe.done === probe.total) pysimFsRenderTree(); + } + } catch (e) { + error = (e && e.message) ? e.message : String(e); + } + pysimFsRenderTree(); + const secs = ((Date.now() - started) / 1000).toFixed(1); + let summary = t('Probed:') + ' ' + probe.done + '/' + probe.total + ' ' + t('files') + ' — ' + + probe.present + ' ' + t('present') + ', ' + probe.absent + ' ' + t('absent') + ' (' + secs + 's)'; + if (error) summary = t('Error') + ': ' + error + ' — ' + summary; + else if (probe.stop) summary = t('Stopped') + ' — ' + summary; + _pysimFsProbe = null; + pysimFsProbeUi(summary, false); +} + function pysimFsRenderTree() { const el = document.getElementById('pysim-fs-tree'); if (!pysimFsTreeRoot) { el.innerHTML = '(loading)'; return; } @@ -6004,8 +6086,12 @@ function pysimFsRenderNode(node, depth) { } html += '
    '; if (isDir && node.children && node.expanded) { - for (const child of pysimFsSortChildren(node.children, pysimFsSort)) { - html += pysimFsRenderNode(child, depth + 1); + if (!node.children.length) { + html += '
    ' + pad + '    ' + esc(t('(empty)')) + '
    '; + } else { + for (const child of pysimFsSortChildren(node.children, pysimFsSort)) { + html += pysimFsRenderNode(child, depth + 1); + } } } return html; @@ -9147,6 +9233,17 @@ const LANG_RU = { 'Phone simulator': 'Симулятор телефона', 'Sort:': 'Сортировка:', 'Name': 'Имя', + 'Probe all files': 'Проверить все файлы', + 'Stop': 'Остановить', + 'Probing:': 'Проверка:', + 'discovering': 'поиск файлов', + 'files': 'файлов', + 'present': 'есть', + 'absent': 'нет', + 'Probed:': 'Проверено:', + 'Stopped': 'Остановлено', + '(empty)': '(пусто)', + 'Error': 'Ошибка', 'No server connection': 'Нет соединения с сервером', 'Server connected, no card equipped': 'Сервер подключён, карта не подключена', 'Card equipped': 'Карта подключена', diff --git a/frontend/sw.js b/frontend/sw.js index a005d2a..aa2a56f 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v115'; +const CACHE = 'otaman-v116'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/fs_load.test.js b/frontend/tests/fs_load.test.js new file mode 100644 index 0000000..408f5a1 --- /dev/null +++ b/frontend/tests/fs_load.test.js @@ -0,0 +1,96 @@ +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) { + const re = new RegExp('(?:async\\s+)?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 src.slice(m.index, i + 1); +} + +let code = ''; +code += extractFunc(html, 'getParentSel') + '\n'; +code += extractFunc(html, 'pysimFsLoadChildren') + '\n'; +code += 'globalThis.pysimCustomInject = () => {};\n'; +eval(code); + +let calls = []; +let responses = []; +let renders = 0; + +function setup() { + calls = []; + responses = []; + renders = 0; + globalThis.pysimFetch = async (p, body) => { + calls.push({ path: p, body: JSON.parse(JSON.stringify(body)) }); + const r = responses.shift(); + if (r instanceof Error) throw r; + return JSON.parse(JSON.stringify(r)); + }; + globalThis.pysimFsRenderTree = () => { renders++; }; + const node = { name: 'DF.USIM', fid: '7fff', isDir: true, children: null, exists: null, parent: { name: 'MF', fid: '3f00' } }; + return node; +} + +test('tree error payload marks the directory as absent', async () => { + const node = setup(); + responses = [ + { success: false, error: 'SW ... 6a82', exists: false }, + { success: false, error: 'SW ... 6a82', exists: false }, + ]; + await pysimFsLoadChildren(node); + assert.strictEqual(node.exists, false); + assert.deepStrictEqual(node.children, []); + assert.strictEqual(renders, 1); + assert.strictEqual(calls.length, 2); + assert.strictEqual(calls[0].body.parent_sel, 'MF'); +}); + +test('error payload without exists is not treated as an empty listing', async () => { + const node = setup(); + responses = [ + { success: false, error: 'boom' }, + { success: false, error: 'boom' }, + ]; + await pysimFsLoadChildren(node); + assert.strictEqual(node.exists, false); + assert.deepStrictEqual(node.children, []); + assert.strictEqual(renders, 1); + assert.strictEqual(calls.length, 1); +}); + +test('retry without parent_sel succeeds and maps children', async () => { + const node = setup(); + responses = [ + { exists: false }, + { exists: true, children: [{ name: 'EF.IMSI', fid: '6f07', isDir: false }] }, + ]; + await pysimFsLoadChildren(node); + assert.strictEqual(node.exists, true); + assert.strictEqual(node.children.length, 1); + assert.strictEqual(node.children[0].parent, node); + assert.strictEqual(node.children[0].exists, true); + assert.strictEqual(calls[1].body.parent_sel, undefined); +}); + +test('empty successful listing keeps the directory present', async () => { + const node = setup(); + responses = [{ exists: true, children: [] }]; + await pysimFsLoadChildren(node); + assert.strictEqual(node.exists, true); + assert.deepStrictEqual(node.children, []); +}); diff --git a/frontend/tests/fs_probe.test.js b/frontend/tests/fs_probe.test.js new file mode 100644 index 0000000..a44ff86 --- /dev/null +++ b/frontend/tests/fs_probe.test.js @@ -0,0 +1,124 @@ +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) { + const re = new RegExp('(?:async\\s+)?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 src.slice(m.index, i + 1); +} + +let code = 'var pysimFsTreeRoot = null;\nvar _pysimFsProbe = null;\nvar pysimFsSort = "fid";\n'; +for (const fn of ['getParentSel', 'pysimFsSortChildren', 'pysimFsLoadChildren', 'pysimFsSelectBody', 'pysimFsProbeUi', 'pysimFsProbeAll']) { + code += extractFunc(html, fn) + '\n'; +} +code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimCustomInject = () => {};\n'; +eval(code); + +function fakeEl() { + const classes = new Set(); + return { + textContent: '', + attrs: {}, + classList: { + add: (...cs) => cs.forEach(c => classes.add(c)), + remove: (...cs) => cs.forEach(c => classes.delete(c)), + contains: c => classes.has(c), + toggle: (c, on) => { if (on === undefined ? !classes.has(c) : on) classes.add(c); else classes.delete(c); }, + }, + setAttribute(k, v) { this.attrs[k] = v; }, + }; +} + +let els = {}; +let calls = []; + +function setup(routes) { + els = { 'pysim-fs-probe-status': fakeEl(), 'pysim-fs-probe-btn': fakeEl() }; + calls = []; + globalThis.document = { getElementById: id => els[id] || null }; + globalThis.pysimFetch = async (p, body) => { + calls.push({ path: p, body: body || {} }); + for (const r of routes) { + if (r.path !== p) continue; + if (r.name && (!body || body.name !== r.name)) continue; + return typeof r.reply === 'function' ? r.reply(body, calls) : JSON.parse(JSON.stringify(r.reply)); + } + throw new Error('unexpected fetch ' + p + ' ' + JSON.stringify(body)); + }; + globalThis.pysimFsRenderTree = () => {}; +} + +function root(children) { + pysimFsTreeRoot = { name: 'MF', fid: '3f00', isDir: true, expanded: true, exists: true, children: null, parent: null }; + pysimFsTreeRoot.children = children.map(c => Object.assign({ children: null, expanded: false, exists: true, parent: pysimFsTreeRoot }, c)); + return pysimFsTreeRoot; +} + +const df = (name, fid) => ({ name, fid, isDir: true }); +const ef = (name, fid) => ({ name, fid, isDir: false }); +const selectNames = () => calls.filter(c => c.path === '/api/select').map(c => c.body.name); + +test('probes dirs and files, including custom entries, and reports counts', async () => { + root([df('DF.A', '5f01'), ef('EF.ROOT', '2f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]); + let sawStop = null; + setup([ + { path: '/api/tree', name: 'DF.A', reply: { exists: true, children: [{ name: 'EF.1', fid: '6f01', isDir: false }] } }, + { path: '/api/select', name: 'EF.1', reply: () => { sawStop = els['pysim-fs-probe-btn'].textContent; return { exists: true }; } }, + { path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } }, + { path: '/api/select', name: 'EF.CUSTOM', reply: { exists: true } }, + ]); + await pysimFsProbeAll(); + assert.strictEqual(sawStop, 'Stop'); + assert.deepStrictEqual(selectNames(), ['EF.1', 'EF.ROOT', 'EF.CUSTOM']); + assert.strictEqual(pysimFsTreeRoot.children[0].children[0].exists, true); + assert.strictEqual(pysimFsTreeRoot.children[1].exists, false); + assert.strictEqual(pysimFsTreeRoot.children[2].exists, true); + const status = els['pysim-fs-probe-status'].textContent; + assert.match(status, /4\/4 files/); + assert.match(status, /3 present/); + assert.match(status, /1 absent/); + assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files'); + assert.strictEqual(els['pysim-fs-probe-btn'].attrs['data-l10n'], 'Probe all files'); +}); + +test('an absent directory is marked and its subtree is never fetched', async () => { + root([df('DF.B', '5f02'), ef('EF.ROOT', '2f01')]); + setup([ + { path: '/api/tree', name: 'DF.B', reply: { success: false, error: 'SW 6a82', exists: false } }, + { path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } }, + ]); + await pysimFsProbeAll(); + assert.strictEqual(pysimFsTreeRoot.children[0].exists, false); + assert.deepStrictEqual(selectNames(), ['EF.ROOT']); + assert.strictEqual(calls.filter(c => c.path === '/api/tree' && c.body.name === 'DF.B').length, 2); + const status = els['pysim-fs-probe-status'].textContent; + assert.match(status, /2\/2 files/); + assert.match(status, /0 present/); + assert.match(status, /2 absent/); +}); + +test('stop halts the walk and still reports a summary', async () => { + root([ef('EF.X', '6f01'), ef('EF.Y', '6f02')]); + setup([ + { path: '/api/select', name: 'EF.X', reply: () => { _pysimFsProbe.stop = true; return { exists: true }; } }, + ]); + await pysimFsProbeAll(); + assert.deepStrictEqual(selectNames(), ['EF.X']); + const status = els['pysim-fs-probe-status'].textContent; + assert.ok(status.startsWith('Stopped —'), status); + assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files'); +}); diff --git a/frontend/tests/fs_render.test.js b/frontend/tests/fs_render.test.js new file mode 100644 index 0000000..e938c23 --- /dev/null +++ b/frontend/tests/fs_render.test.js @@ -0,0 +1,58 @@ +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) { + const re = new RegExp('(?:async\\s+)?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 src.slice(m.index, i + 1); +} + +let code = 'var pysimFsSort = "fid";\n'; +code += extractFunc(html, 'pysimFsSortChildren') + '\n'; +code += extractFunc(html, 'pysimFsRenderNode') + '\n'; +code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n'; +eval(code); + +const ef = (name, fid) => ({ name, fid, isDir: false, exists: true, children: null, expanded: false }); +const df = (name, fid, extra) => Object.assign({ name, fid, isDir: true, exists: true, children: null, expanded: false }, extra || {}); + +test('absent directory renders a cross and no expand toggle', () => { + const node = df('DF.WLAN', '5f40', { exists: false, children: [] }); + const out = pysimFsRenderNode(node, 0); + assert.ok(out.includes('✗'), out); + assert.ok(!out.includes('pysimFsToggleDir'), out); + assert.ok(!out.includes('▶'), out); +}); + +test('expanded empty directory shows the (empty) placeholder', () => { + const node = df('DF.EMPTY', '5f00', { children: [], expanded: true }); + const out = pysimFsRenderNode(node, 0); + assert.ok(out.includes('(empty)'), out); +}); + +test('expanded directory with children renders no placeholder', () => { + const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: true }); + const out = pysimFsRenderNode(node, 0); + assert.ok(out.includes('EF.IMSI'), out); + assert.ok(!out.includes('(empty)'), out); +}); + +test('collapsed directory hides its children', () => { + const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: false }); + const out = pysimFsRenderNode(node, 0); + assert.ok(!out.includes('EF.IMSI'), out); +}); diff --git a/frontend/tests/html.test.js b/frontend/tests/html.test.js index b640a21..4214c34 100644 --- a/frontend/tests/html.test.js +++ b/frontend/tests/html.test.js @@ -61,3 +61,10 @@ test('custom files form has add/save and cancel controls', () => { assert.ok(html.includes("event.key==='Enter')pysimCustomSubmit()")); assert.ok(!html.includes('pysimCustomAdd')); }); + +test('file manager has a probe-all-files button and status line', () => { + assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-needs="card"/); + assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-l10n="Probe all files"/); + assert.ok(html.includes('onclick="pysimFsProbeAll()"')); + assert.ok(html.includes('id="pysim-fs-probe-status"')); +}); diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index 195b1cd..5ee5599 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -2184,7 +2184,7 @@ class PysimHandler(BaseHTTPRequestHandler): sys.stderr.write('Handler error: %s\n' % e) if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e): _handle_card_disconnect() - err = {'success': False, 'error': str(e)} + err = {'success': False, 'error': str(e), 'exists': False} self._send_json(err, 500) self._log_resp(err) elif self.path == '/api/menu-select':