Change details: labelled APDU/time in the header, no zero counts (v2.7.4)

- the header's trailing meta is labelled instead of bare numbers:
  "ADF/EF_IMSI (6F07) — READ BINARY · APDU #490 · 0251.199 sec" (the record
  part already read "Record 2 / 5")
- the summary counts only what happened: "{c} bytes changed · {n} bytes newly
  observed" drops whichever part is zero ("4 bytes changed" alone when nothing
  was first observed) and an empty diff reads "No changes" instead of
  "0 bytes changed, 0 bytes newly observed"
- i18n: the combined key splits into '{c} bytes changed' / '{n} bytes newly
  observed' plus 'No changes', 'APDU' and 'sec' ('{c}','{n}' placeholders keep
  the existing replace-based substitution; the Russian keeps the established
  wording: «Изменено байт: {c}», «Впервые наблюдается байт: {n}», «с»)
- tests: the header and summary assertions follow the labels, a short-file
  case pins the omitted zero, and an empty-diff case pins 'No changes';
  527 Python + 76 Node tests
- PWA_VERSION/pyproject/version.py 2.7.4, SW cache simtrace-analyser-v84
This commit is contained in:
catarrh committed 2026-10-18 00:46:42 +03:00
1 parent 71a94f5f50
commit 06c6e952a9
6 files changed
+38 -13

No files matched your search

+17 -6
View File
@@ -2320,8 +2320,12 @@ function renderFileDiff(image) {
meta.push(`${t('Record')} ${image.record}` + meta.push(`${t('Record')} ${image.record}` +
(image.n_records ? ` / ${image.n_records}` : '')); (image.n_records ? ` / ${image.n_records}` : ''));
} }
if (image.index !== null && image.index !== undefined) meta.push(`#${image.index}`); if (image.index !== null && image.index !== undefined) {
if (image.elapsed !== null && image.elapsed !== undefined) meta.push(fmt_elapsed(image.elapsed)); meta.push(`${t('APDU')} #${image.index}`);
}
if (image.elapsed !== null && image.elapsed !== undefined) {
meta.push(`${fmt_elapsed(image.elapsed)} ${t('sec')}`);
}
el('file-diff-sub').textContent = `${image.path || image.name || ''} — ${meta.join(' · ')}`; el('file-diff-sub').textContent = `${image.path || image.name || ''} — ${meta.join(' · ')}`;
const before = hexImageHtml(image, 'before'); const before = hexImageHtml(image, 'before');
const after = hexImageHtml(image, 'after'); const after = hexImageHtml(image, 'after');
@@ -2337,9 +2341,12 @@ function renderFileDiff(image) {
used.fresh ? `<span class="hex-legend-item"><span class="hex-cell hex-new">aa</span> ${t('newly observed')}</span>` : '', used.fresh ? `<span class="hex-legend-item"><span class="hex-cell hex-new">aa</span> ${t('newly observed')}</span>` : '',
used.unknown ? `<span class="hex-legend-item"><span class="hex-cell hex-unknown">--</span> ${t('never observed')}</span>` : '', used.unknown ? `<span class="hex-legend-item"><span class="hex-cell hex-unknown">--</span> ${t('never observed')}</span>` : '',
].join(''); ].join('');
// Mention only what happened: "0 bytes newly observed" is noise.
const counts = [];
if (changed) counts.push(t('{c} bytes changed').replace('{c}', changed));
if (fresh) counts.push(t('{n} bytes newly observed').replace('{n}', fresh));
el('file-diff-body').innerHTML = el('file-diff-body').innerHTML =
`<div class="hex-summary">${t('{c} bytes changed, {n} bytes newly observed') `<div class="hex-summary">${counts.length ? counts.join(' · ') : t('No changes')}</div>
.replace('{c}', changed).replace('{n}', fresh)}</div>
<div class="hex-pair"> <div class="hex-pair">
<div><h4>${t('Before')}</h4>${before.html}</div> <div><h4>${t('Before')}</h4>${before.html}</div>
<div><h4>${t('After')}</h4>${after.html}</div> <div><h4>${t('After')}</h4>${after.html}</div>
@@ -3373,7 +3380,7 @@ function onSearchChange() {
} }
// ──────────────────── Versioning ──────────────────── // ──────────────────── Versioning ────────────────────
const PWA_VERSION = '2.7.3'; const PWA_VERSION = '2.7.4';
const MIN_SERVER_VERSION = '2.3.0'; const MIN_SERVER_VERSION = '2.3.0';
function parseVersion(v) { function parseVersion(v) {
@@ -3430,7 +3437,11 @@ const LANG_RU = {
'changed': 'изменено', 'changed': 'изменено',
'newly observed': 'впервые наблюдается', 'newly observed': 'впервые наблюдается',
'never observed': 'не наблюдалось', 'never observed': 'не наблюдалось',
'{c} bytes changed, {n} bytes newly observed': 'Изменено байт: {c}; впервые наблюдается байт: {n}', '{c} bytes changed': 'Изменено байт: {c}',
'{n} bytes newly observed': 'Впервые наблюдается байт: {n}',
'No changes': 'Изменений нет',
'APDU': 'APDU',
'sec': 'с',
'Showing the first {n} bytes of the image': 'Показаны первые {n} байт образа', 'Showing the first {n} bytes of the image': 'Показаны первые {n} байт образа',
'The image is frozen (size cap reached) — later changes are not tracked.': 'Образ заморожен (достигнут предел размера) — последующие изменения не отслеживаются.', 'The image is frozen (size cap reached) — later changes are not tracked.': 'Образ заморожен (достигнут предел размера) — последующие изменения не отслеживаются.',
'Close': 'Закрыть', 'Close': 'Закрыть',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simtrace-analyser-v83'; const CACHE = 'simtrace-analyser-v84';
const PRECACHE = [ const PRECACHE = [
'index.html', 'index.html',
+17 -3
View File
@@ -379,7 +379,7 @@ test('Files view: strings carry Russian translations', () => {
for (const s of ['Files', 'Read', 'Update', 'Changed on card!', 'Change details', for (const s of ['Files', 'Read', 'Update', 'Changed on card!', 'Change details',
'Show diff', 'Close', 'Offset', 'Record', 'Before', 'After', 'Show the change', 'Show diff', 'Close', 'Offset', 'Record', 'Before', 'After', 'Show the change',
'changed', 'newly observed', 'never observed', 'changed', 'newly observed', 'never observed',
'{c} bytes changed, {n} bytes newly observed', '{c} bytes changed', '{n} bytes newly observed', 'No changes', 'sec',
'Showing the first {n} bytes of the image', 'Showing the first {n} bytes of the image',
'The image is frozen (size cap reached) — later changes are not tracked.', 'The image is frozen (size cap reached) — later changes are not tracked.',
'No file operations recorded', 'Failed to load files', 'Loading…']) { 'No file operations recorded', 'Failed to load files', 'Loading…']) {
@@ -508,9 +508,10 @@ test('Change details modal: header, summary, legend and both views', () => {
}); });
const sub = el('file-diff-sub').textContent; const sub = el('file-diff-sub').textContent;
assert.match(sub, /MF\/EF_ICCID \(2FE2\) — READ BINARY/); assert.match(sub, /MF\/EF_ICCID \(2FE2\) — READ BINARY/);
assert.match(sub, /#12/); assert.match(sub, /APDU #12/);
assert.match(sub, /0003\.250 sec/);
const body = el('file-diff-body').innerHTML; const body = el('file-diff-body').innerHTML;
assert.match(body, /2 bytes changed, 4 bytes newly observed/); assert.match(body, /2 bytes changed · 4 bytes newly observed/);
assert.match(body, /hex-cell hex-changed/); // legend + the changed byte assert.match(body, /hex-cell hex-changed/); // legend + the changed byte
assert.match(body, /hex-cell hex-new/); assert.match(body, /hex-cell hex-new/);
assert.match(body, /hex-cell hex-unknown/); assert.match(body, /hex-cell hex-unknown/);
@@ -542,6 +543,8 @@ test('Change details: a short file shows no filler, no offset, no stray legend',
const body = el('file-diff-body').innerHTML; const body = el('file-diff-body').innerHTML;
assert.doesNotMatch(body, /hex-unknown/); // no '--' cells at all assert.doesNotMatch(body, /hex-unknown/); // no '--' cells at all
assert.doesNotMatch(body, /hex-addr/); // a single row: no offset column assert.doesNotMatch(body, /hex-addr/); // a single row: no offset column
// The summary mentions only what happened.
assert.match(body, /^<div class="hex-summary">1 bytes changed<\/div>/);
assert.match(body, /hex-cell hex-changed/); assert.match(body, /hex-cell hex-changed/);
// The legend lists only what the views show: 'changed' (the summary line // The legend lists only what the views show: 'changed' (the summary line
// above still counts the zero "newly observed" bytes). // above still counts the zero "newly observed" bytes).
@@ -551,6 +554,17 @@ test('Change details: a short file shows no filler, no offset, no stray legend',
assert.doesNotMatch(legend, /newly observed|never observed/); assert.doesNotMatch(legend, /newly observed|never observed/);
}); });
test('Change details: an empty diff says so instead of counting zeros', () => {
renderFileDiff({
name: 'EF_ICCID', path: 'MF/EF_ICCID (2FE2)', op: 'READ BINARY',
record: null, size: 2, index: 1, elapsed: 0.5, ranges: [],
observed_ranges: [], before: [{offset: 0, hex: 'aaaa'}],
after: [{offset: 0, hex: 'aaaa'}], image_truncated: false,
});
assert.match(el('file-diff-body').innerHTML,
/^<div class="hex-summary">No changes<\/div>/);
});
test('Change details: a short record keeps its never-observed tail', () => { test('Change details: a short record keeps its never-observed tail', () => {
// A 9-byte record with 4 bytes observed: the row ends at 9 (no filler // A 9-byte record with 4 bytes observed: the row ends at 9 (no filler
// past it) and '--' marks the bytes that were never observed. // past it) and '--' marks the bytes that were never observed.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
[project] [project]
name = "simtrace-analyser" name = "simtrace-analyser"
version = "2.7.3" version = "2.7.4"
description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)" description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)"
readme = {file = "README.md", content-type = "text/markdown"} readme = {file = "README.md", content-type = "text/markdown"}
license = {text = "GPL-2.0-or-later"} license = {text = "GPL-2.0-or-later"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the SIMtrace Analyser version.""" """Single source of truth for the SIMtrace Analyser version."""
__version__ = '2.7.3' __version__ = '2.7.4'
+1 -1
View File
@@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase):
class TestVersion(unittest.TestCase): class TestVersion(unittest.TestCase):
def test_package_version(self): def test_package_version(self):
from simtrace2_pysniff import __version__ from simtrace2_pysniff import __version__
self.assertEqual(__version__, '2.7.3') self.assertEqual(__version__, '2.7.4')
def test_status_includes_version(self): def test_status_includes_version(self):
import os import os