diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 3de52a0..87d0fce 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -338,8 +338,8 @@

Сценарий A — Работа с файлами, не входящими в модель pySim («Пользовательские файлы»)

  1. Получите FID целевого файла (документация вендора или анализ ATR/файловой системы; такие файлы часто отсутствуют в открытых спецификациях).
  2. -
  3. Откройте вкладку «Картридер» → подвкладку «Пользовательские файлы».
  4. -
  5. Введите полный путь (например, 3F00/7F20/6F46) и псевдоним (например, EF.SPN).
  6. +
  7. Откройте вкладку «Профайлер» → подвкладку «Пользовательские файлы».
  8. +
  9. Выберите корень, введите путь родительского DF (стандартный DF из дерева или пользовательский DF, любой вложенности — при вводе появляются подсказки), 4-hex FID и псевдоним (например, EF.SPN).
  10. Нажмите Добавить — файл появится в дереве курсивом (непроверенный).
  11. Кликните по файлу для проверки существования; при успехе (9000) он работает как обычный файл.
  12. Читайте, редактируйте и сохраняйте hex-данные; переключайте Данные как на карте / Декодированные данные.
  13. @@ -409,7 +409,7 @@

    Пользовательские файлы

    -

    Добавление файлов, не покрытых моделью pySim. Пользовательский файл всегда имеет полный путь от MF или ADF (ADF.USIM, ADF.ISIM): выберите корень, родительский DF (сам корень или DF, уже добавленный здесь — родитель должен существовать до добавления его файлов), 4-hex FID и псевдоним (EF.…/DF.…; префикс определяет, EF это или DF). Канонический путь убирает прежнюю неоднозначность, когда один и тот же файл можно было описать и относительно, и абсолютно. Добавленные файлы появляются в дереве «Файловый менеджер»; в каждой строке есть «Редактировать» (путь + псевдоним; изменение пути DF перепривязывает дочерние записи — кнопка становится «Сохранить», «Отмена» отменяет) и «Удалить» (удаление DF удаляет и его дочерние записи после подтверждения). Список сохраняется в localStorage; обмен — Экспорт в JSON / Экспорт в файл / Импорт; старые относительные пути (например, a153/4954) разрешаются при загрузке, неразрешимые отбрасываются и отмечаются в списке.

    +

    Добавление файлов, не покрытых моделью pySim. Пользовательский файл всегда имеет полный путь от MF или ADF (ADF.USIM, ADF.ISIM): выберите корень, введите путь родительского DF (сам корень, стандартный DF из дерева файлового менеджера или пользовательский DF — любой вложенности, с подсказками при вводе; родитель, ещё не встречавшийся в дереве, остаётся допустимым и помечается ), 4-hex FID и псевдоним (EF.…/DF.…; префикс определяет, EF это или DF). Канонический путь убирает прежнюю неоднозначность, когда один и тот же файл можно было описать и относительно, и абсолютно. Добавленные файлы появляются в дереве «Файловый менеджер»; в каждой строке есть «Редактировать» (путь + псевдоним; изменение пути DF перепривязывает дочерние записи — кнопка становится «Сохранить», «Отмена» отменяет) и «Удалить» (удаление DF удаляет и его дочерние записи после подтверждения). Список сохраняется в localStorage; обмен — Экспорт в JSON / Экспорт в файл / Импорт; старые относительные пути (например, a153/4954) разрешаются при загрузке, неразрешимые отбрасываются и отмечаются в списке.

    6. Симулятор телефона

    diff --git a/frontend/help.html b/frontend/help.html index ea260e1..a6967b6 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -338,8 +338,8 @@

    Scenario A — Working with files not in pySim’s model (Custom files)

    1. Obtain the FID of the target file (vendor documentation or ATR/file-system analysis; such files are often not in public specs).
    2. -
    3. Open the Card reader tab → Custom files sub-tab.
    4. -
    5. Enter the full path (e.g. 3F00/7F20/6F46) and an alias (e.g. EF.SPN).
    6. +
    7. Open the Profiler tab → Custom files sub-tab.
    8. +
    9. Pick the root, type the parent DF path (a standard DF from the tree or a custom DF, any depth — suggestions appear while you type), the 4-hex FID and an alias (e.g. EF.SPN).
    10. Click Add — the file appears in the tree in italics (unverified).
    11. Click the file to verify existence; on success (9000) it behaves like a normal file.
    12. Read, edit and save hex data; toggle Raw/Decoded views.
    13. @@ -409,7 +409,7 @@

      Custom files

      -

      Add files that pySim’s model does not cover. A custom file always has a full path rooted at MF or an ADF (ADF.USIM, ADF.ISIM): pick the root, the parent DF (the root itself, or a DF that was already added here — a parent must exist before its files can be added), the 4-hex FID and an alias (EF.…/DF.…; the prefix decides whether the entry is an EF or a DF). The canonical path removes the old ambiguity where the same file could be described both relatively and absolutely. Added files appear in the File manager tree; each row has Edit (path + alias; changing a DF’s path re-points its child entries — the button becomes Save, Cancel aborts) and Delete (deleting a DF also deletes its child entries after a confirmation). The list persists in localStorage and can be shared with Export as JSON / Export to file / Import; legacy relative paths (e.g. a153/4954) are resolved on load, unresolvable ones are dropped and reported in the list.

      +

      Add files that pySim’s model does not cover. A custom file always has a full path rooted at MF or an ADF (ADF.USIM, ADF.ISIM): pick the root, type the parent DF path (the root itself, a standard DF known from the file-manager tree, or a custom DF — any depth, with suggestions while you type; a parent that has not been seen in the tree yet stays valid and is marked ), the 4-hex FID and an alias (EF.…/DF.…; the prefix decides whether the entry is an EF or a DF). The canonical path removes the old ambiguity where the same file could be described both relatively and absolutely. Added files appear in the File manager tree; each row has Edit (path + alias; changing a DF’s path re-points its child entries — the button becomes Save, Cancel aborts) and Delete (deleting a DF also deletes its child entries after a confirmation). The list persists in localStorage and can be shared with Export as JSON / Export to file / Import; legacy relative paths (e.g. a153/4954) are resolved on load, unresolvable ones are dropped and reported in the list.

      6. Phone simulator

      diff --git a/frontend/index.html b/frontend/index.html index a6c87c4..309645b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@
      -

      OTAMan SIM OTA with a Human Face v2.2.10

      +

      OTAMan SIM OTA with a Human Face v2.2.11

      @@ -744,7 +744,8 @@
      - + +
      @@ -757,7 +758,7 @@
      -
      Full path from MF or an ADF; the parent DF must be defined here first.
      +
      The parent may be any DF path — a standard DF from the tree or a custom DF, any depth; parents not yet seen in the tree are marked ⚠.
      @@ -6317,6 +6318,7 @@ function pysimCustomInject(node) { const fid = pysimCustomFid(c); const existing = (node.children || []).find(ch => (ch.fid || '').toUpperCase() === fid); if (existing) { + if (existing.modelName === undefined) existing.modelName = existing.name; existing.name = c.name; existing.kind = c.kind; existing.custom = true; @@ -6338,6 +6340,37 @@ function pysimCustomInject(node) { } } +// Re-apply the custom-file list to the already-loaded tree: drop injected +// nodes whose entry is gone, restore renamed model nodes, inject anew. +function pysimCustomRefreshTree() { + if (!pysimFsTreeRoot) return; + const prune = node => { + if (!node.children) return; + node.children = node.children.filter(ch => { + if (!ch.custom) return true; + if (pysimCustomFiles.some(c => c.path === ch.customPath)) return true; + if (ch.modelName !== undefined) { + ch.name = ch.modelName; + delete ch.modelName; + delete ch.custom; + delete ch.customPath; + delete ch.kind; + return true; + } + return false; + }); + }; + const walk = node => { + prune(node); + pysimCustomInject(node); + for (const ch of (node.children || [])) { + if (ch.isDir) walk(ch); + } + }; + walk(pysimFsTreeRoot); + pysimFsRenderTree(); +} + let pysimFsSort = localStorage.getItem('otaman_fs_sort') === 'name' ? 'name' : 'fid'; function pysimFsSortChildren(children, mode) { @@ -6583,6 +6616,23 @@ function pysimFsFindNode(name, root) { return null; } +// Find a node by its canonical path ("MF/7F20/6F46", "ADF.USIM/6F07"), so +// custom-file parents can be resolved against the loaded tree (standard DFs +// included, at any depth). Returns null when the path is not loaded yet. +function pysimFsFindNodeByPath(path) { + const want = pysimCustomNormPath(path); + if (!want || !pysimFsTreeRoot) return null; + const walk = node => { + if (pysimCustomNormPath(pysimFsNodePath(node)) === want) return node; + for (const c of (node.children || [])) { + const hit = walk(c); + if (hit) return hit; + } + return null; + }; + return walk(pysimFsTreeRoot); +} + async function pysimFsToggleDir(name) { const node = pysimFsFindNode(name, pysimFsTreeRoot); if (!node || !node.isDir) return; @@ -8730,20 +8780,66 @@ function pysimCustomFid(entry) { return entry.path.split('/').pop(); } function pysimCustomParent(entry) { return entry.path.split('/').slice(0, -1).join('/'); } function pysimCustomRoot(entry) { return entry.path.split('/')[0]; } +// Parent DF suggestions for the current root: the root, custom DFs and every +// DF node known from the loaded file-manager tree (standard pySim files), +// at any depth. +function pysimCustomKnownDfPaths(root) { + const out = []; + const seen = new Set(); + const add = p => { if (p && !seen.has(p)) { seen.add(p); out.push(p); } }; + add(root); + for (const c of pysimCustomFiles) { + if (c.kind === 'df' && pysimCustomRoot(c) === root) add(c.path); + } + const walk = node => { + for (const ch of (node.children || [])) { + if (!ch.isDir) continue; + const p = pysimFsNodePath(ch); + if (p && p.split('/')[0] === root) add(p); + walk(ch); + } + }; + if (pysimFsTreeRoot) walk(pysimFsTreeRoot); + return out.sort(); +} + +// Where a parent path comes from: root, a custom DF, a DF of the loaded tree, +// a known non-DF (error), or unknown (allowed, flagged as a warning). +function pysimCustomParentStatus(parentPath) { + const p = pysimCustomNormPath(parentPath); + if (!p || p.split('/').length <= 1) return {status: 'root', source: null}; + const c = pysimCustomFiles.find(e => e.path === p); + if (c) return {status: c.kind === 'df' ? 'df' : 'not-df', source: 'custom'}; + const node = (typeof pysimFsFindNodeByPath === 'function') ? pysimFsFindNodeByPath(p) : null; + if (node) return {status: node.isDir ? 'df' : 'not-df', source: 'tree'}; + return {status: 'unknown', source: null}; +} + function pysimCustomValidate(root, parentPath, fid, name, entries, editIndex) { - if (!CUSTOM_ROOTS.includes(root)) { + parentPath = pysimCustomNormPath(parentPath) || root; + // a bare FID chain typed without the root is completed from the selector + const first = parentPath.split('/')[0]; + if (!CUSTOM_ROOTS.includes(first) && /^[0-9A-F]{4}$/.test(first)) { + parentPath = pysimCustomNormPath(root + '/' + parentPath); + } + if (!CUSTOM_ROOTS.includes(parentPath.split('/')[0])) { return {error: t('Root must be MF, ADF.USIM or ADF.ISIM')}; } + const segs = parentPath.split('/'); + if (segs.length > 1 && !segs.slice(1).every(s => /^[0-9A-F]{4}$/.test(s))) { + return {error: t('Parent must be the root or a chain of 4-hex FIDs (e.g. MF/7F20)')}; + } fid = (fid || '').trim().toUpperCase(); if (!/^[0-9A-F]{4}$/.test(fid)) return {error: t('FID must be 4 hex characters')}; const kind = pysimCustomKindForName(name); if (!kind) return {error: t('Alias must start with "EF." or "DF."')}; - if (parentPath !== root) { - const idx = entries.findIndex((e, i) => i !== editIndex && e.path === parentPath); - if (idx < 0) return {error: t('Parent DF is not defined as a custom file:') + ' ' + parentPath}; - if (entries[idx].kind !== 'df') return {error: t('Parent is not a DF:') + ' ' + parentPath}; + // A parent known here to be an EF is an error; a parent that is a standard + // DF (or not seen yet) is fine - the tree resolves it later. + const parent = entries.find((e, i) => i !== editIndex && e.path === parentPath); + if (parent && parent.kind !== 'df') { + return {error: t('Parent is not a DF:') + ' ' + parentPath}; } - const path = (parentPath === root ? root : parentPath) + '/' + fid; + const path = parentPath + '/' + fid; if (entries.some((e, i) => i !== editIndex && e.path === path)) { return {error: t('File already defined:') + ' ' + path}; } @@ -8836,25 +8932,36 @@ function pysimCustomRenderRoots() { } function pysimCustomRenderParents() { - const sel = document.getElementById('pysim-cf-parent'); - if (!sel) return; + const list = document.getElementById('pysim-cf-parent-list'); + if (!list) return; const root = document.getElementById('pysim-cf-root').value || CUSTOM_ROOTS[0]; - const prev = sel.value; - let html = ''; - for (const c of pysimCustomFiles) { - if (c.kind !== 'df' || pysimCustomRoot(c) !== root) continue; - html += ''; - } - sel.innerHTML = html; - if (prev && (prev === root || pysimCustomFiles.some(c => c.path === prev && c.kind === 'df'))) { - sel.value = prev; + let html = ''; + for (const p of pysimCustomKnownDfPaths(root)) { + const c = pysimCustomFiles.find(e => e.path === p && e.kind === 'df'); + html += ''; } + list.innerHTML = html; } function pysimCustomRootChanged() { + const sel = document.getElementById('pysim-cf-root'); + const inp = document.getElementById('pysim-cf-parent'); + const first = pysimCustomNormPath(inp && inp.value).split('/')[0]; + if (CUSTOM_ROOTS.includes(first) && first !== sel.value) inp.value = sel.value; pysimCustomRenderParents(); } +// Keep the root selector (and its suggestions) in step with a typed parent path. +function pysimCustomParentInput() { + const sel = document.getElementById('pysim-cf-root'); + const inp = document.getElementById('pysim-cf-parent'); + const first = pysimCustomNormPath(inp && inp.value).split('/')[0]; + if (CUSTOM_ROOTS.includes(first) && sel.value !== first) { + sel.value = first; + pysimCustomRenderParents(); + } +} + function pysimCustomSubmit() { const root = document.getElementById('pysim-cf-root').value; const parentPath = document.getElementById('pysim-cf-parent').value || root; @@ -8863,6 +8970,11 @@ function pysimCustomSubmit() { const name = nameEl.value.trim(); const res = pysimCustomValidate(root, parentPath, fid, name, pysimCustomFiles, pysimCustomEditIndex); if (res.error) { alert(res.error); return; } + const parent = pysimCustomParent({path: res.path}); + if (pysimCustomParentStatus(parent).status === 'not-df') { + alert(t('Parent is not a DF:') + ' ' + parent); + return; + } if (pysimCustomEditIndex !== null) { const oldPath = pysimCustomFiles[pysimCustomEditIndex].path; if (oldPath !== res.path) pysimCustomRewriteDescendants(pysimCustomFiles, oldPath, res.path); @@ -8873,6 +8985,7 @@ function pysimCustomSubmit() { pysimCustomSave(); pysimCustomRender(); pysimCustomEditCancel(); + pysimCustomRefreshTree(); } function pysimCustomEdit(i) { @@ -8919,6 +9032,7 @@ function pysimCustomRemove(i) { } pysimCustomSave(); pysimCustomRender(); + pysimCustomRefreshTree(); } function pysimCustomRender() { @@ -8936,8 +9050,11 @@ function pysimCustomRender() { } for (let i = 0; i < pysimCustomFiles.length; i++) { const c = pysimCustomFiles[i]; + const warn = pysimCustomParentStatus(pysimCustomParent(c)).status === 'unknown' + ? ' ' + : ''; html += '
      '; - html += '' + esc(c.path) + ' ' + esc(c.name) + ''; + html += '' + warn + esc(c.path) + ' ' + esc(c.name) + ''; html += '(' + esc(c.kind.toUpperCase()) + ')'; html += ''; html += ''; @@ -8969,6 +9086,7 @@ async function pysimCustomImport(text) { pysimCustomFiles = res.files; pysimCustomSave(); pysimCustomRender(); + pysimCustomRefreshTree(); const el = document.getElementById('pysim-cf-io'); el.value = 'Imported ' + added + ' file(s)' + (res.dropped ? ', dropped ' + res.dropped : '') + '.'; setTimeout(() => { el.classList.add('hidden'); }, 2000); @@ -11309,14 +11427,14 @@ const LANG_RU = { 'Parent DF': 'Родительский DF', 'FID (4 hex)': 'FID (4 hex)', 'Alias (EF. / DF.)': 'Псевдоним (EF. / DF.)', - 'Full path from MF or an ADF; the parent DF must be defined here first.': 'Полный путь от MF или ADF; родительский DF должен быть создан здесь заранее.', + 'The parent may be any DF path — a standard DF from the tree or a custom DF, any depth; parents not yet seen in the tree are marked ⚠.': 'Родителем может быть любой путь DF — стандартный DF из дерева или пользовательский DF, любой вложенности; DF, ещё не встречавшиеся в дереве, помечаются ⚠.', 'Root must be MF, ADF.USIM or ADF.ISIM': 'Корень должен быть MF, ADF.USIM или ADF.ISIM', 'FID must be 4 hex characters': 'FID должен состоять из 4 hex-символов', 'Alias must start with "EF." or "DF."': 'Псевдоним должен начинаться с «EF.» или «DF.»', - 'Parent DF is not defined as a custom file:': 'Родительский DF не создан как пользовательский файл:', + 'Parent must be the root or a chain of 4-hex FIDs (e.g. MF/7F20)': 'Родитель — корень или цепочка 4-hex FID (например, MF/7F20)', 'Parent is not a DF:': 'Родитель не является DF:', + 'Parent DF not seen in the tree yet': 'Родительский DF ещё не встречался в дереве', 'File already defined:': 'Файл уже задан:', - '— root —': '— корень —', 'and': 'и', 'child file(s)?': 'дочерних файлов?', 'Dropped invalid legacy entries:': 'Отброшено некорректных старых записей:', diff --git a/frontend/sw.js b/frontend/sw.js index 59f95a8..9dda01f 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v175'; +const CACHE = 'otaman-v176'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/custom_files.test.js b/frontend/tests/custom_files.test.js index 465c5cb..7d82950 100644 --- a/frontend/tests/custom_files.test.js +++ b/frontend/tests/custom_files.test.js @@ -21,17 +21,18 @@ function extractFunc(src, name) { return src.slice(m.index, i + 1); } -let code = 'var pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\nvar _pysimCustomDropped = 0;\n'; +let code = 'var pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\nvar _pysimCustomDropped = 0;\nvar pysimFsTreeRoot = null;\n'; for (const fn of ['pysimCustomNormPath', 'pysimCustomKindForName', 'pysimCustomFid', - 'pysimCustomParent', 'pysimCustomRoot', 'pysimCustomValidate', - 'pysimCustomRewriteDescendants', 'pysimCustomNormalizeEntries', 'pysimCustomSave', - 'pysimCustomRenderRoots', 'pysimCustomRenderParents', 'pysimCustomRootChanged', - 'pysimCustomSubmit', 'pysimCustomEdit', 'pysimCustomEditCancel', - 'pysimCustomRemove', 'pysimCustomRender']) { + 'pysimCustomParent', 'pysimCustomRoot', 'pysimCustomKnownDfPaths', 'pysimCustomParentStatus', + 'pysimCustomValidate', 'pysimCustomRewriteDescendants', 'pysimCustomNormalizeEntries', + 'pysimCustomSave', 'pysimCustomRenderRoots', 'pysimCustomRenderParents', + 'pysimCustomRootChanged', 'pysimCustomParentInput', 'pysimCustomSubmit', + 'pysimCustomEdit', 'pysimCustomEditCancel', 'pysimCustomRemove', 'pysimCustomRender', + 'pysimFsNodePath', 'pysimFsFindNodeByPath', 'pysimCustomInject', 'pysimCustomRefreshTree']) { code += extractFunc(html, fn) + '\n'; } code += html.match(/const CUSTOM_ROOTS = \[[^\]]*\];/)[0].replace('const ', 'var ') + '\n'; -code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n'; +code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimFsRenderTree = () => {};\n'; eval(code); function fakeEl(id) { @@ -53,6 +54,7 @@ function setup(entries) { const els = { 'pysim-cf-root': fakeEl('pysim-cf-root'), 'pysim-cf-parent': fakeEl('pysim-cf-parent'), + 'pysim-cf-parent-list': fakeEl('pysim-cf-parent-list'), 'pysim-cf-fid': fakeEl('pysim-cf-fid'), 'pysim-cf-name': fakeEl('pysim-cf-name'), 'pysim-cf-list': fakeEl('pysim-cf-list'), @@ -73,6 +75,7 @@ function setup(entries) { pysimCustomFiles = (entries || []).map(e => Object.assign({}, e)); pysimCustomEditIndex = null; _pysimCustomDropped = 0; + pysimFsTreeRoot = null; pysimCustomRenderRoots(); pysimCustomRenderParents(); return els; @@ -115,12 +118,24 @@ test('migration resolves legacy relative paths and drops the unresolvable', () = assert.strictEqual(res.dropped, 2); }); -test('validation requires a defined parent DF and a valid FID/alias', () => { +test('validation accepts standard/unknown parents but rejects known EFs', () => { const files = [{ path: 'MF/A153', name: 'DF.A1', kind: 'df' }]; - // parent not defined - assert.match(pysimCustomValidate('MF', 'MF/A999', '6F46', 'EF.SPN', files, null).error, /not defined/); + // a parent that is not a custom entry is accepted: it may be a standard DF + // from the card model, or simply not seen in the tree yet + const unknown = pysimCustomValidate('MF', 'MF/A999', '6F46', 'EF.SPN', files, null); + assert.strictEqual(unknown.error, null); + assert.strictEqual(unknown.path, 'MF/A999/6F46'); // parent defined -> ok assert.strictEqual(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN', files, null).path, 'MF/A153/6F46'); + // a bare FID chain typed without the root is completed from the selector + assert.strictEqual(pysimCustomValidate('MF', 'A153', '6F46', 'EF.SPN', files, null).path, 'MF/A153/6F46'); + // deep chains are fine + assert.strictEqual(pysimCustomValidate('MF', 'MF/7F20/5F01', '6F46', 'EF.DEEP', [], null).path, 'MF/7F20/5F01/6F46'); + // a parent known here to be an EF is rejected + assert.match(pysimCustomValidate('MF', 'MF/A153/6F46', '1234', 'EF.X', + files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), null).error, /not a DF/); + // parent segments must be 4-hex FIDs + assert.match(pysimCustomValidate('MF', 'MF/FOO', '6F46', 'EF.SPN', files, null).error, /4-hex/); // bad FID assert.match(pysimCustomValidate('MF', 'MF', '6F4', 'EF.SPN', files, null).error, /4 hex/); // bad alias @@ -135,21 +150,62 @@ test('validation requires a defined parent DF and a valid FID/alias', () => { assert.match(pysimCustomValidate('MFX', 'MFX', '6F46', 'EF.SPN', files, null).error, /Root/); }); -test('root and parent selectors list roots and defined DFs', () => { +test('root and parent suggestions list roots, custom DFs and tree DFs', () => { const els = setup([ { path: 'MF/A153', name: 'DF.A1', kind: 'df' }, { path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' }, { path: 'ADF.USIM/6F07', name: 'EF.IMSI', kind: 'ef' }, ]); assert.deepStrictEqual(els['pysim-cf-root'].options(), ['MF', 'ADF.USIM', 'ADF.ISIM']); - const parents = els['pysim-cf-parent'].options(); + let parents = els['pysim-cf-parent-list'].options(); assert.ok(parents.includes('MF')); assert.ok(parents.includes('MF/A153')); assert.ok(!parents.includes('MF/A153/4954'), 'EFs are not parent options'); + // DFs known from the loaded file tree are suggested, at any depth + pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, parent: null, children: [] }; + const df = { name: 'DF.TELECOM', fid: '7F10', isDir: true, parent: pysimFsTreeRoot, children: [] }; + const sub = { name: 'DF.SUB', fid: '5F01', isDir: true, parent: df, children: [] }; + df.children = [sub]; + pysimFsTreeRoot.children = [df]; + pysimCustomRenderParents(); + parents = els['pysim-cf-parent-list'].options(); + assert.ok(parents.includes('MF/7F10')); + assert.ok(parents.includes('MF/7F10/5F01')); // the ADF root is always a valid parent els['pysim-cf-root'].value = 'ADF.USIM'; pysimCustomRootChanged(); - assert.deepStrictEqual(els['pysim-cf-parent'].options(), ['ADF.USIM']); + assert.deepStrictEqual(els['pysim-cf-parent-list'].options(), ['ADF.USIM']); +}); + +test('parent status classifies root, custom, tree and unknown parents', () => { + setup([ + { path: 'MF/A153', name: 'DF.A1', kind: 'df' }, + { path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' }, + ]); + assert.strictEqual(pysimCustomParentStatus('MF').status, 'root'); + assert.strictEqual(pysimCustomParentStatus('MF/A153').status, 'df'); + assert.strictEqual(pysimCustomParentStatus('MF/A153/4954').status, 'not-df'); + assert.strictEqual(pysimCustomParentStatus('MF/FFFF').status, 'unknown'); + pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, parent: null, children: [] }; + const df = { name: 'DF.GSM', fid: '7F20', isDir: true, parent: pysimFsTreeRoot, children: [] }; + df.children = [{ name: 'EF.SPN', fid: '6F46', isDir: false, parent: df, children: null }]; + pysimFsTreeRoot.children = [df]; + assert.strictEqual(pysimCustomParentStatus('MF/7F20').status, 'df'); + assert.strictEqual(pysimCustomParentStatus('MF/7F20/6F46').status, 'not-df'); + assert.strictEqual(pysimCustomParentStatus('MF/7F20/9999').status, 'unknown'); +}); + +test('import keeps canonical entries whose parent is outside the custom list', () => { + const res = pysimCustomNormalizeEntries([ + { path: '3F00/7f20/5f01/6f46', name: 'EF.DEEP' }, + { path: 'mf/ffff/6f46', name: 'EF.ORPHAN' }, + { path: 'MF/6F46', name: 'NOPE' }, + ], []); + assert.deepStrictEqual(res.files, [ + { path: 'MF/7F20/5F01/6F46', name: 'EF.DEEP', kind: 'ef' }, + { path: 'MF/FFFF/6F46', name: 'EF.ORPHAN', kind: 'ef' }, + ]); + assert.strictEqual(res.dropped, 1); }); test('submit adds files under the root and under a defined DF', () => { @@ -165,13 +221,17 @@ test('submit adds files under the root and under a defined DF', () => { pysimCustomSubmit(); assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['MF/6F46', 'MF/A153', 'MF/A153/4954']); - // adding under an undefined parent is rejected - fill(els, 'MF', 'MF', '1111', 'EF.X'); - els['pysim-cf-parent'].value = 'MF/A153'; - els['pysim-cf-fid'].value = '2222'; - els['pysim-cf-name'].value = 'BAD'; + // a parent that is not a custom entry is allowed (it may be a standard DF + // the tree has not loaded yet) + fill(els, 'MF', 'MF/A153', '2222', 'EF.ORPHAN'); pysimCustomSubmit(); - assert.ok(globalThis.alertCalls.length === 1); + assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), + ['MF/6F46', 'MF/A153', 'MF/A153/4954', 'MF/A153/2222']); + assert.strictEqual(globalThis.alertCalls.length, 0); + // a parent known here to be an EF is rejected + fill(els, 'MF', 'MF/A153/4954', '3333', 'EF.NOPE'); + pysimCustomSubmit(); + assert.deepStrictEqual(globalThis.alertCalls, ['Parent is not a DF: MF/A153/4954']); }); test('editing a DF FID rewrites its descendants', () => { diff --git a/frontend/tests/custom_inject.test.js b/frontend/tests/custom_inject.test.js index a5ae495..f1caa80 100644 --- a/frontend/tests/custom_inject.test.js +++ b/frontend/tests/custom_inject.test.js @@ -21,10 +21,11 @@ function extractFunc(src, name) { return src.slice(m.index, i + 1); } -let code = 'var pysimCustomFiles = [];\n'; -for (const fn of ['pysimFsNodePath', 'pysimCustomInject', 'pysimCustomParent', 'pysimCustomFid']) { +let code = 'var pysimCustomFiles = [];\nvar pysimFsTreeRoot = null;\n'; +for (const fn of ['pysimFsNodePath', 'pysimCustomInject', 'pysimCustomRefreshTree', 'pysimCustomParent', 'pysimCustomFid']) { code += extractFunc(html, fn) + '\n'; } +code += 'globalThis.pysimFsRenderTree = () => {};\n'; eval(code); function node(name, fid, parent, children) { @@ -100,3 +101,25 @@ test('injected DFs are directories and can host their own children', () => { assert.strictEqual(df.children.length, 1); assert.strictEqual(df.children[0].name, 'EF.UNDER-NEW'); }); + +test('refresh restores renamed model nodes and drops injected ones', () => { + const t = tree(); + pysimFsTreeRoot = t.mf; + pysimCustomFiles = [{ path: 'MF/5F03/6F46', name: 'EF.RENAMED', kind: 'ef' }]; + pysimCustomRefreshTree(); + assert.strictEqual(t.dfC.children[0].name, 'EF.RENAMED'); + assert.strictEqual(t.dfC.children[0].custom, true); + // deleting the entry brings the model name back + pysimCustomFiles = []; + pysimCustomRefreshTree(); + assert.strictEqual(t.dfC.children[0].name, 'EF.X'); + assert.ok(!t.dfC.children[0].custom); + assert.strictEqual(t.dfC.children[0].modelName, undefined); + // injected nodes disappear together with their entry + pysimCustomFiles = [{ path: 'MF/5F10', name: 'DF.NEW', kind: 'df' }]; + pysimCustomRefreshTree(); + assert.ok(t.mf.children.some(c => c.fid === '5F10')); + pysimCustomFiles = []; + pysimCustomRefreshTree(); + assert.ok(!t.mf.children.some(c => c.fid === '5F10')); +}); diff --git a/pyproject.toml b/pyproject.toml index 41cb073..7075f93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-otaman-server" -version = "2.2.10" +version = "2.2.11" description = "HTTP REST server wrapping pysim for the OTAMan PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index c96ae83..02a0a34 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter from osmocom.tlv import BER_TLV_IE -VERSION = '2.2.10' +VERSION = '2.2.11' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE