+
Position:
-
+
First Script
@@ -2358,6 +2284,10 @@ function buildScriptChainingRow(rowIdx, hint = '') {
Also Last Script
+
+
+ Keep chaining info across reset (RFM)
+
@@ -2383,10 +2313,14 @@ function buildScriptChainingRow(rowIdx, hint = '') {
function updateChainingFlags(rowIdx) {
const first = document.querySelector(`input[name="chaining-${rowIdx}"][value="first"]`);
const intermediate = document.querySelector(`input[name="chaining-${rowIdx}"][value="intermediary"]`);
+ const lastCheckbox = document.querySelector(`input[name="chaining-${rowIdx}-last"]`);
+ const keepCheckbox = document.querySelector(`input[name="chaining-${rowIdx}-keep"]`);
if (first && intermediate) {
if (first.checked) intermediate.checked = false;
if (intermediate.checked) first.checked = false;
}
+ if (lastCheckbox) lastCheckbox.disabled = !!(first && first.checked);
+ if (keepCheckbox) keepCheckbox.disabled = !!(first && !first.checked);
updateBerRow(document.querySelector(`[data-ber-idx="${rowIdx}"] .ber-type`));
}
@@ -2411,15 +2345,19 @@ function genScriptChainingValue(row, tag) {
const firstRadio = row.querySelector(`input[name="chaining-${rowIdx}"][value="first"]`);
const intermediateRadio = row.querySelector(`input[name="chaining-${rowIdx}"][value="intermediary"]`);
const lastCheckbox = row.querySelector(`input[name="chaining-${rowIdx}-last"]`);
+ const keepCheckbox = row.querySelector(`input[name="chaining-${rowIdx}-keep"]`);
const scriptIdInput = row.querySelector('.chaining-script-id');
const additionalInput = row.querySelector('.chaining-additional');
- let flags = 0;
- if (firstRadio && firstRadio.checked) flags |= 0x01;
- if (intermediateRadio && intermediateRadio.checked) flags |= 0x02;
- if (lastCheckbox && lastCheckbox.checked) flags |= 0x04;
+ let flags = '';
+ if (firstRadio && firstRadio.checked) {
+ flags = (keepCheckbox && keepCheckbox.checked) ? '11' : '01';
+ } else if (intermediateRadio && intermediateRadio.checked) {
+ flags = (lastCheckbox && lastCheckbox.checked) ? '03' : '02';
+ }
+ if (!flags) return '';
- let value = flags.toString(16).padStart(2, '0').toUpperCase();
+ let value = flags;
const scriptId = (scriptIdInput.value || '').replace(/[^0-9a-fA-F]/g, '');
if (scriptId) {
@@ -2444,7 +2382,7 @@ function genBerPcValue(row, tag) {
const dst = pc.querySelector('.ber-pc-dst').value;
let value = '8103' + cmdNum.toString(16).padStart(2,'0').toUpperCase();
- const typeBytes = {refresh:'01',display:'21',tone:'31',custom:'00'};
+ const typeBytes = {refresh:'01',display:'21',tone:'20'};
value += (typeBytes[pct]||'00') + qual;
value += '8202' + src + dst;
@@ -2456,7 +2394,6 @@ function genBerPcValue(row, tag) {
const buf = [];
for (const c of text) { const cd=c.charCodeAt(0); buf.push((cd>>8)&0xFF,cd&0xFF); }
value += '8D' + berLenStr(buf.length) + buf.map(b=>b.toString(16).padStart(2,'0').toUpperCase()).join('');
- value += '04' + berLenStr(1) + '80';
} else {
try {
const septets = gsm7TextToSeptets(text);
@@ -2468,6 +2405,14 @@ function genBerPcValue(row, tag) {
}
}
}
+ const durEnable = pc.querySelector('.ber-pc-dur-enable');
+ if (durEnable && durEnable.checked) {
+ const durVal = parseInt(pc.querySelector('.ber-pc-dur-val').value);
+ if (!isNaN(durVal) && durVal >= 1 && durVal <= 255) {
+ const durUnit = pc.querySelector('.ber-pc-dur-unit').value;
+ value += '84' + berLenStr(2) + durUnit + durVal.toString(16).padStart(2,'0').toUpperCase();
+ }
+ }
const alpha = (pc.querySelector('.ber-pc-alpha').value||'').replace(/[^0-9a-fA-F]/g,'');
if (alpha) value += '85' + berLenStr(alpha.length/2) + alpha;
} else if (pct === 'tone') {
@@ -2475,10 +2420,7 @@ function genBerPcValue(row, tag) {
value += '8E01' + tone;
} else if (pct === 'refresh') {
const flist = (pc.querySelector('.ber-pc-flist').value||'').replace(/[^0-9a-fA-F]/g,'');
- if (flist) value += '12' + berLenStr(flist.length/2) + flist;
- } else {
- const hex = (pc.querySelector('.ber-hex').value||'').replace(/[^0-9a-fA-F]/g,'');
- if (hex) value += hex;
+ if (flist) value += '92' + berLenStr(flist.length/2) + flist;
}
return tag + berLenStr(value.length/2) + value;
@@ -2489,44 +2431,6 @@ function berLenStr(n) {
return '81' + n.toString(16).padStart(2,'0').toUpperCase();
}
-function updateSwMaskDisplay(row) {
- const triggerType = row.querySelector('.error-trigger-type');
- if (!triggerType) return;
- const maskInput = row.querySelector('.error-sw-mask');
- const valueInput = row.querySelector('.error-sw-value');
- const display = row.querySelector('.error-sw-mask-display');
-
- let mask = '0000';
-
- switch (triggerType.value) {
- case 'any-sw':
- mask = '0000';
- break;
- case 'specific-sw':
- mask = 'FFFF';
- break;
- case 'sw-range':
- mask = maskInput.value || '6A00';
- break;
- case 'cmd-number':
- mask = '0000';
- break;
- }
-
- display.textContent = mask.toUpperCase();
-}
-
-document.addEventListener('DOMContentLoaded', () => {
- document.addEventListener('change', (e) => {
- if (e.target.classList.contains('error-trigger-type') ||
- e.target.classList.contains('error-sw-mask')) {
- const row = e.target.closest('.ber-row');
- if (row) updateSwMaskDisplay(row);
- updateBerRow(e.target);
- }
- });
-});
-
function genBerTlv() {
const fmt = document.getElementById('ber-format').value;
let content = '';
diff --git a/frontend/tests/ber.test.js b/frontend/tests/ber.test.js
new file mode 100644
index 0000000..bdadd12
--- /dev/null
+++ b/frontend/tests/ber.test.js
@@ -0,0 +1,280 @@
+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);
+}
+
+class StubEl {
+ constructor(value = '') {
+ this.value = value;
+ this.checked = false;
+ this.disabled = false;
+ this.children = [];
+ this.parentElement = null;
+ this.nextElementSibling = null;
+ }
+ querySelector(sel) {
+ return this._find(sel);
+ }
+ querySelectorAll(sel) {
+ const out = [];
+ this._findAll(sel, out);
+ return out;
+ }
+ _find(sel) {
+ for (const c of this.children) {
+ if (c._matches(sel)) return c;
+ const r = c._find(sel);
+ if (r) return r;
+ }
+ return null;
+ }
+ _findAll(sel, out) {
+ for (const c of this.children) {
+ if (c._matches(sel)) out.push(c);
+ c._findAll(sel, out);
+ }
+ }
+ _matches(sel) {
+ if (sel.startsWith('.')) return this.cls === sel.slice(1);
+ if (sel.startsWith('input[name="')) {
+ const m = sel.match(/^input\[name="([^"]+)"\]\[value="([^"]+)"\]$/);
+ if (m) return this.name === m[1] && this.value === m[2];
+ const m2 = sel.match(/^input\[name="([^"]+)"\]$/);
+ if (m2) return this.name === m2[1];
+ }
+ return false;
+ }
+}
+
+function el(cls, value = '') {
+ const e = new StubEl(value);
+ e.cls = cls;
+ return e;
+}
+
+function buildRow() {
+ const row = new StubEl();
+ row.cls = 'ber-row';
+ row.dataset = { berIdx: '0' };
+ const body = el('error-action-body');
+ const type = el('error-action-type', 'proactive');
+ type.nextElementSibling = body;
+ body.parentElement = row;
+ row.children.push(type, body);
+ return row;
+}
+
+function buildPcHtml(extraFields) {
+ // Build the .ber-pc tree expected by genBerPcValue
+ const pc = el('ber-pc');
+ const type = el('ber-pc-type', 'display');
+ const num = el('ber-pc-num', '1');
+ const qual = el('ber-pc-qual', '01');
+ const src = el('ber-pc-src', '81');
+ const dst = el('ber-pc-dst', '82');
+ const text = el('ber-pc-text', 'HI');
+ const enc = el('ber-pc-enc', 'ucs2');
+ const alpha = el('ber-pc-alpha', '');
+ const durEnable = el('ber-pc-dur-enable');
+ durEnable.checked = true;
+ const durUnit = el('ber-pc-dur-unit', '01');
+ const durVal = el('ber-pc-dur-val', '30');
+ pc.children.push(type, num, qual, src, dst, text, enc, alpha, durEnable, durUnit, durVal);
+ if (extraFields) extraFields(pc);
+ return pc;
+}
+
+const FNS = ['berLenStr', 'gsm7TextToSeptets', 'gsm7Encode', 'genBerPcValue', 'genErrorActionValue',
+ 'genScriptChainingValue'];
+let code = '';
+for (const f of FNS) code += extractFunc(html, f) + '\n';
+const berTagsMatch = html.match(/const BER_TAGS = \{[^}]*\};/);
+const berQualMatch = html.match(/const BER_QUAL = \{[\s\S]*?\n\};/);
+const berDevMatch = html.match(/const BER_DEVICES = \[[\s\S]*?\];/);
+const berTonesMatch = html.match(/const BER_TONES = \[[\s\S]*?\];/);
+const gsm7AlphaMatch = html.match(/const GSM7_ALPHABET = \[[\s\S]*?\n\];/);
+const gsm7ExtMatch = html.match(/const GSM7_EXT_MAP = \{[\s\S]*?\n\};/);
+for (const m of [berTagsMatch, berQualMatch, berDevMatch, berTonesMatch, gsm7AlphaMatch, gsm7ExtMatch]) {
+ if (!m) throw new Error('constant not found');
+ code = m[0] + '\n' + code;
+}
+code += '\nvar __berConsts = {BER_TAGS, BER_QUAL, BER_DEVICES, BER_TONES, GSM7_ALPHABET, GSM7_EXT_MAP};';
+eval(code);
+const {BER_TAGS, BER_QUAL, BER_DEVICES, BER_TONES, GSM7_ALPHABET, GSM7_EXT_MAP} = __berConsts;
+
+function mkRow(type) {
+ const row = new StubEl();
+ row.cls = 'ber-row';
+ row.dataset = { berIdx: '0' };
+ const body = el('error-action-body');
+ const actionType = el('error-action-type', type);
+ actionType.nextElementSibling = body;
+ row.children.push(actionType, body);
+ return row;
+}
+
+test('BER_QUAL.refresh matches TS 102 223 §8.6 mapping', () => {
+ const r = BER_QUAL.refresh;
+ assert.strictEqual(r[0][0], '00');
+ assert.strictEqual(r[0][1], 'NAA Initialization and Full File Change Notification');
+ assert.strictEqual(r[4][0], '04');
+ assert.strictEqual(r[4][1], 'UICC Reset');
+ assert.strictEqual(r[5][1], 'NAA Application Reset (not for 2G SIM)');
+ assert.strictEqual(r[6][1], 'NAA Session Reset (not for 2G SIM)');
+ assert.strictEqual(r[7][1], 'Steering of Roaming');
+ assert.strictEqual(r.length, 11);
+});
+
+test('genBerPcValue DISPLAY TEXT GSM7 "HI"', () => {
+ const pc = buildPcHtml();
+ pc.children.find(c => c.cls === 'ber-pc-enc').value = 'gsm7';
+ pc.children.find(c => c.cls === 'ber-pc-dur-enable').checked = false;
+ const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
+ assert.strictEqual(val, '810D8103012101820281828D02C824');
+});
+
+test('genBerPcValue DISPLAY TEXT UCS2 "HI" + 30s duration', () => {
+ const pc = buildPcHtml();
+ const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
+ // 81 13 8103 01 21 01 8202 81 82 8D 04 0048 0049 84 02 01 1E
+ assert.strictEqual(val, '81138103012101820281828D04004800498402011E');
+});
+
+test('genBerPcValue duration disabled omits 84', () => {
+ const pc = buildPcHtml();
+ pc.children.find(c => c.cls === 'ber-pc-dur-enable').checked = false;
+ const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
+ assert.ok(!val.includes('84'));
+});
+
+test('genBerPcValue PLAY TONE uses type byte 20', () => {
+ const pc = buildPcHtml();
+ pc.children.find(c => c.cls === 'ber-pc-type').value = 'tone';
+ pc.children.find(c => c.cls === 'ber-pc-qual').value = '00';
+ pc.children.push(el('ber-pc-tone', '01'));
+ const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
+ assert.strictEqual(val, '810C8103012000820281828E0101');
+});
+
+test('genBerPcValue REFRESH uses CR-set file list tag 92', () => {
+ const pc = buildPcHtml();
+ pc.children.find(c => c.cls === 'ber-pc-type').value = 'refresh';
+ pc.children.find(c => c.cls === 'ber-pc-qual').value = '04';
+ pc.children.push(el('ber-pc-flist', '3F00'));
+ const val = genBerPcValue({ querySelector: (s) => s === '.ber-pc' ? pc : null }, '81');
+ assert.strictEqual(val, '810D81030101048202818292023F00');
+});
+
+test('genErrorActionValue noaction emits 82 00', () => {
+ const row = mkRow('noaction');
+ const val = genErrorActionValue(row, '82');
+ assert.strictEqual(val, '8200');
+});
+
+test('genErrorActionValue reference emits 82 01 ref', () => {
+ const row = mkRow('reference');
+ row.children[1].children.push(el('error-ref', '05'));
+ const val = genErrorActionValue(row, '82');
+ assert.strictEqual(val, '820105');
+});
+
+test('genErrorActionValue reference rejects out-of-range ref', () => {
+ const row = mkRow('reference');
+ row.children[1].children.push(el('error-ref', '80'));
+ assert.strictEqual(genErrorActionValue(row, '82'), '');
+});
+
+test('genErrorActionValue proactive wraps proactive command', () => {
+ const row = mkRow('proactive');
+ const pc = buildPcHtml();
+ row.children[1].children.push(pc);
+ const val = genErrorActionValue(row, '82');
+ // DISPLAY TEXT UCS2 "HI" + 30s inside Error Action
+ assert.strictEqual(val, '82138103012101820281828D04004800498402011E');
+});
+
+test('genScriptChainingValue first emits 01', () => {
+ const row = new StubEl();
+ row.dataset = { berIdx: '0' };
+ const first = el('chaining-first', 'first');
+ first.name = 'chaining-0';
+ first.checked = true;
+ const interm = el('chaining-inter', 'intermediary');
+ interm.name = 'chaining-0';
+ const last = el('chaining-last', 'last');
+ last.name = 'chaining-0-last';
+ const keep = el('chaining-keep', 'keep');
+ keep.name = 'chaining-0-keep';
+ row.children.push(first, interm, last, keep, el('chaining-script-id'), el('chaining-additional'));
+ const val = genScriptChainingValue(row, '83');
+ assert.strictEqual(val, '830101');
+});
+
+test('genScriptChainingValue first with keep emits 11', () => {
+ const row = new StubEl();
+ row.dataset = { berIdx: '0' };
+ const first = el('chaining-first', 'first');
+ first.name = 'chaining-0';
+ first.checked = true;
+ const interm = el('chaining-inter', 'intermediary');
+ interm.name = 'chaining-0';
+ const last = el('chaining-last', 'last');
+ last.name = 'chaining-0-last';
+ const keep = el('chaining-keep', 'keep');
+ keep.name = 'chaining-0-keep';
+ keep.checked = true;
+ row.children.push(first, interm, last, keep, el('chaining-script-id'), el('chaining-additional'));
+ const val = genScriptChainingValue(row, '83');
+ assert.strictEqual(val, '830111');
+});
+
+test('genScriptChainingValue intermediary last emits 03', () => {
+ const row = new StubEl();
+ row.dataset = { berIdx: '0' };
+ const first = el('chaining-first', 'first');
+ first.name = 'chaining-0';
+ const interm = el('chaining-inter', 'intermediary');
+ interm.name = 'chaining-0';
+ interm.checked = true;
+ const last = el('chaining-last', 'last');
+ last.name = 'chaining-0-last';
+ last.checked = true;
+ const keep = el('chaining-keep', 'keep');
+ keep.name = 'chaining-0-keep';
+ row.children.push(first, interm, last, keep, el('chaining-script-id'), el('chaining-additional'));
+ const val = genScriptChainingValue(row, '83');
+ assert.strictEqual(val, '830103');
+});
+
+test('genScriptChainingValue no position emits empty', () => {
+ const row = new StubEl();
+ row.dataset = { berIdx: '0' };
+ const first = el('chaining-first', 'first');
+ first.name = 'chaining-0';
+ const interm = el('chaining-inter', 'intermediary');
+ interm.name = 'chaining-0';
+ const last = el('chaining-last', 'last');
+ last.name = 'chaining-0-last';
+ const keep = el('chaining-keep', 'keep');
+ keep.name = 'chaining-0-keep';
+ row.children.push(first, interm, last, keep, el('chaining-script-id'), el('chaining-additional'));
+ assert.strictEqual(genScriptChainingValue(row, '83'), '');
+});
\ No newline at end of file