Files
otaman/frontend/tests/custom_files.test.js
T
catarrh b0b8d02fe7 custom files: any DF parent path at any depth (v2.2.11)
The parent field was a select over custom DFs only, so a standard DF (e.g.
MF/7F20) could not be a parent and canonical entries under one were dropped
on import. It is now a free-text path with suggestions from the root, custom
DFs and DFs of the loaded tree (any depth); a bare FID chain is completed
from the root selector. Validation accepts standard or not-yet-seen parents
(flagged in the list), rejects a parent known to be an EF, and import keeps
such entries. Custom-file edits re-inject the loaded tree: new nodes appear
at once, renamed model nodes are restored and injected nodes removed when
their entry goes away. Help updated (the form lives in Profiler, not Card
reader). SW cache otaman-v176.
2026-09-16 23:47:30 +03:00

300 lines
13 KiB
JavaScript

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('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 pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\nvar _pysimCustomDropped = 0;\nvar pysimFsTreeRoot = null;\n';
for (const fn of ['pysimCustomNormPath', 'pysimCustomKindForName', 'pysimCustomFid',
'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;\nglobalThis.pysimFsRenderTree = () => {};\n';
eval(code);
function fakeEl(id) {
const classes = new Set();
return {
id, value: '', innerHTML: '', textContent: '', attrs: {}, focused: 0,
classList: {
add: (...cs) => cs.forEach(c => classes.add(c)),
remove: (...cs) => cs.forEach(c => classes.delete(c)),
contains: c => classes.has(c),
},
setAttribute(k, v) { this.attrs[k] = v; },
focus() { this.focused++; },
options() { return [...this.innerHTML.matchAll(/value="([^"]+)"/g)].map(m => m[1]); },
};
}
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'),
'pysim-cf-add-btn': fakeEl('pysim-cf-add-btn'),
'pysim-cf-cancel-btn': fakeEl('pysim-cf-cancel-btn'),
};
els['pysim-cf-cancel-btn'].classList.add('hidden');
const store = {};
globalThis.localStorage = {
getItem: k => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
};
globalThis.document = { getElementById: id => els[id] || null };
globalThis.alertCalls = [];
globalThis.alert = m => { globalThis.alertCalls.push(m); };
globalThis.confirmResult = true;
globalThis.confirm = () => globalThis.confirmResult;
pysimCustomFiles = (entries || []).map(e => Object.assign({}, e));
pysimCustomEditIndex = null;
_pysimCustomDropped = 0;
pysimFsTreeRoot = null;
pysimCustomRenderRoots();
pysimCustomRenderParents();
return els;
}
function fill(els, root, parent, fid, name) {
els['pysim-cf-root'].value = root;
pysimCustomRenderParents();
els['pysim-cf-parent'].value = parent;
els['pysim-cf-fid'].value = fid;
els['pysim-cf-name'].value = name;
}
test('helpers derive canonical roots, parents, FIDs and kinds', () => {
assert.strictEqual(pysimCustomNormPath('3f00/7f20/6f46'), 'MF/7F20/6F46');
assert.strictEqual(pysimCustomNormPath(' mf / a153 '), 'MF/A153');
assert.strictEqual(pysimCustomNormPath(''), '');
assert.strictEqual(pysimCustomRoot({ path: 'ADF.USIM/6F07' }), 'ADF.USIM');
assert.strictEqual(pysimCustomParent({ path: 'MF/A153/4954' }), 'MF/A153');
assert.strictEqual(pysimCustomParent({ path: 'MF/6F46' }), 'MF');
assert.strictEqual(pysimCustomFid({ path: 'MF/A153/4954' }), '4954');
assert.strictEqual(pysimCustomKindForName('EF.SPN'), 'ef');
assert.strictEqual(pysimCustomKindForName('DF.GSM'), 'df');
assert.strictEqual(pysimCustomKindForName('XX.SPN'), null);
});
test('migration resolves legacy relative paths and drops the unresolvable', () => {
const res = pysimCustomNormalizeEntries([
{ path: '3f00/a153', name: 'DF.A1' },
{ path: 'a153/4954', name: 'EF.SPNS', fid: '4954', parentFid: 'a153' },
{ path: 'a153/4955', name: 'EF.SMSCS' },
{ path: 'ffff/1111', name: 'EF.ORPHAN' }, // no such custom DF -> dropped
{ path: 'MF/6F46', name: 'BAD.NAME' }, // invalid alias -> dropped
], []);
assert.deepStrictEqual(res.files, [
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
{ path: 'MF/A153/4955', name: 'EF.SMSCS', kind: 'ef' },
]);
assert.strictEqual(res.dropped, 2);
});
test('validation accepts standard/unknown parents but rejects known EFs', () => {
const files = [{ path: 'MF/A153', name: 'DF.A1', kind: 'df' }];
// 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
assert.match(pysimCustomValidate('MF', 'MF', '6F46', 'SPN', files, null).error, /EF\.|DF\./);
// duplicate
assert.match(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN',
files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), null).error, /already defined/);
// editing the same entry is not a duplicate
assert.strictEqual(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN',
files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), 1).error, null);
// root must be known
assert.match(pysimCustomValidate('MFX', 'MFX', '6F46', 'EF.SPN', files, null).error, /Root/);
});
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']);
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-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', () => {
const els = setup([]);
fill(els, 'MF', 'MF', '6F46', 'EF.SPN');
pysimCustomSubmit();
assert.deepStrictEqual(pysimCustomFiles, [{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' }]);
assert.strictEqual(els['pysim-cf-fid'].value, '');
// add a DF, then a file under it
fill(els, 'MF', 'MF', 'A153', 'DF.A1');
pysimCustomSubmit();
fill(els, 'MF', 'MF/A153', '4954', 'EF.SPNS');
pysimCustomSubmit();
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path),
['MF/6F46', 'MF/A153', 'MF/A153/4954']);
// 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.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', () => {
const els = setup([
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
{ path: 'MF/A153/4955', name: 'EF.SMSCS', kind: 'ef' },
]);
pysimCustomEdit(0);
assert.strictEqual(els['pysim-cf-fid'].value, 'A153');
els['pysim-cf-fid'].value = 'A154';
pysimCustomSubmit();
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path),
['MF/A154', 'MF/A154/4954', 'MF/A154/4955']);
assert.strictEqual(pysimCustomEditIndex, null);
});
test('editing an entry to another defined path is rejected', () => {
const els = setup([
{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' },
{ path: 'MF/6F44', name: 'EF.SPN2', kind: 'ef' },
]);
pysimCustomEdit(0);
els['pysim-cf-fid'].value = '6F44';
pysimCustomSubmit();
assert.deepStrictEqual(globalThis.alertCalls, ['File already defined: MF/6F44']);
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['MF/6F46', 'MF/6F44']);
assert.strictEqual(pysimCustomEditIndex, 0);
});
test('deleting a DF cascades to its children only after confirmation', () => {
setup([
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' },
]);
globalThis.confirmResult = false;
pysimCustomRemove(0);
assert.strictEqual(pysimCustomFiles.length, 3, 'cancelled delete must keep everything');
globalThis.confirmResult = true;
pysimCustomRemove(0);
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['MF/6F46']);
});
test('render shows the canonical path, kind and row actions', () => {
const els = setup([
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
]);
pysimCustomRender();
const out = els['pysim-cf-list'].innerHTML;
assert.match(out, /MF\/A153/);
assert.match(out, /MF\/A153\/4954/);
assert.match(out, /\(DF\)/);
assert.match(out, /pysimCustomEdit\(0\)/);
assert.match(out, /pysimCustomRemove\(1\)/);
});
test('render flags dropped legacy entries', () => {
const els = setup([]);
_pysimCustomDropped = 3;
pysimCustomRender();
assert.match(els['pysim-cf-list'].innerHTML, /3/);
_pysimCustomDropped = 0;
});