Files
simple/tests/test_iccid.py
T
catarrh 6d5d23487a cards: read EF.ICCID at equip and auto-select the matching preset (v2.2.16)
- Server: _decode_iccid (nibble-swapped E.118 digits, trailing-F pad) and
  _read_iccid (best-effort MF/2FE2 read through the parent-scoped select
  helper; the previous selection is restored, the read never raises). The
  equip path records the digit string before the TERMINAL PROFILE, i.e.
  before any CAT session is active; a startup with a card does the same.
  The value is cleared on card removal and exposed as /api/status 'iccid'
  (only while connected).
- PWA: when a connected status update reports a *new* ICCID, the matching
  card preset is selected in both SCP80 views - Secured Packet (sp-card-sel
  + form fill) and RAM (ram-card-sel + _ramCardIdx). Matching normalizes
  digits and accepts the raw EF hex form, leading zeros ignored; a manual
  choice for the same card is kept until the next equip, and a card removal
  re-arms the auto-selection. The status line shows the ICCID.
- Tests: tests/test_iccid.py (decode variants, model + probe read, equip
  recording, disconnect clearing) and frontend/tests/cards_iccid.test.js
  (normalize, find, select, no-override, card swap); the card_state test
  harness stubs the new hook and covers the guard reset.
- Docs: api.md /api/status fields, help EN+RU (SCP80 intro + Cards tab),
  READMEs, AGENTS. SW cache otaman-v183.
2026-09-18 21:05:25 +03:00

158 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Tests for the EF.ICCID helpers (decode + best-effort read after equip).
EF.ICCID (MF/2FE2) is a mandatory transparent EF carrying the nibble-swapped
E.118 digit string with an optional trailing 'F' pad (TS 102 221 13.2 /
TS 151 011 10.2). The equip flow reads it so the PWA can auto-select the
matching card preset; the read is optional and must never break an equip.
"""
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
PROJECTS = Path(__file__).resolve().parents[2]
PY_SIM = PROJECTS / 'pysim'
if str(PY_SIM) not in sys.path:
sys.path.insert(0, str(PY_SIM))
from pysim_otaman_server import server
from pysim_otaman_server.server import _decode_iccid, _read_iccid
class FakeFile:
def __init__(self, fid=None, name=None, parent=None):
self.fid = fid
self.name = name
self.parent = parent
self.sfid = None
self.children = {}
self.applications = {}
class FakeLchan:
def __init__(self, mf, data=None, error=None):
self.selected_file = mf
self.data = data
self.error = error
self.selects = []
self.probes = []
def select_file(self, f, app=None):
self.selected_file = f
self.selects.append(f)
def probe_file(self, fid, app=None):
self.probes.append(fid)
f = FakeFile(fid=fid, name='EF.' + fid.upper(), parent=self.selected_file)
self.selected_file.children[fid] = f
self.selected_file = f
def read_binary(self, length=None, offset=0):
if self.error:
raise self.error
return self.data, '9000'
def make_app(with_iccid=True, data='980711090000640090F8', error=None):
mf = FakeFile('3f00', 'MF')
iccid = None
if with_iccid:
iccid = FakeFile('2fe2', 'EF.ICCID', mf)
mf.children['2fe2'] = iccid
lchan = FakeLchan(mf, data=data, error=error)
rs = SimpleNamespace(mf=mf, lchan=[lchan])
app = SimpleNamespace(rs=rs)
return app, lchan, mf, iccid
class DecodeIccidTest(unittest.TestCase):
def test_live_card_style_content_with_f_pad(self):
# 98 07 11 09 00 00 64 00 90 F8 -> 8970119000004600098
self.assertEqual(_decode_iccid('980711090000640090F8'), '8970119000004600098')
def test_even_length_digit_string_is_kept_whole(self):
self.assertEqual(_decode_iccid('21436587092143658709'), '12345678901234567890')
def test_odd_length_hex_is_rejected(self):
self.assertIsNone(_decode_iccid('98071109000064009F8'))
def test_non_hex_and_empty_are_rejected(self):
self.assertIsNone(_decode_iccid(''))
self.assertIsNone(_decode_iccid(None))
self.assertIsNone(_decode_iccid('zzzz'))
self.assertIsNone(_decode_iccid('abcd')) # swaps to non-digits
self.assertIsNone(_decode_iccid('FFFF')) # only the pad
class ReadIccidTest(unittest.TestCase):
def test_reads_and_decodes_the_model_file(self):
app, lchan, mf, iccid = make_app()
self.assertEqual(_read_iccid(app), '8970119000004600098')
self.assertIs(lchan.selected_file, iccid)
self.assertEqual([f.name for f in lchan.selects], ['MF', 'EF.ICCID'])
self.assertEqual(lchan.probes, [])
def test_missing_model_entry_is_probed_and_detached(self):
app, lchan, mf, _ = make_app(with_iccid=False)
self.assertEqual(_read_iccid(app), '8970119000004600098')
self.assertEqual(lchan.probes, ['2fe2'])
self.assertIs(lchan.selected_file, mf) # previous selection restored
self.assertNotIn('2fe2', mf.children) # probe entry detached
def test_card_errors_are_not_fatal(self):
app, _, _, _ = make_app(error=Exception('SW 6A82'))
self.assertIsNone(_read_iccid(app))
def test_no_app_or_state(self):
self.assertIsNone(_read_iccid(None))
self.assertIsNone(_read_iccid(SimpleNamespace(rs=None)))
class EquipFlowIccidTest(unittest.TestCase):
def _apply(self, read_result):
scc = SimpleNamespace(cat_cla=None)
card = SimpleNamespace(_scc=scc)
app = SimpleNamespace(card=card)
srv = SimpleNamespace(app=app, terminal_profile='FF', card=None, scc=None)
with mock.patch.object(server, '_cancel_menu_timeout'), \
mock.patch.object(server, '_timer_cancel'), \
mock.patch.object(server, '_reset_proactive_log'), \
mock.patch.object(server, '_poll_enable'), \
mock.patch.object(server, '_send_terminal_profile', return_value=(None, None)), \
mock.patch.object(server, '_tlog'), \
mock.patch.object(server, '_read_iccid', return_value=read_result):
server._apply_equipped_card(srv)
return srv
def test_equip_records_the_iccid(self):
srv = self._apply('8970119000004600098')
self.assertEqual(srv.iccid, '8970119000004600098')
def test_unreadable_iccid_leaves_it_none(self):
srv = self._apply(None)
self.assertIsNone(srv.iccid)
def test_disconnect_clears_the_iccid(self):
old_ref = server._server_ref
old_connected = server._CARD_CONNECTED
ref = SimpleNamespace(iccid='8970119000004600098', card='x', scc='y')
try:
server._server_ref = ref
with mock.patch.object(server, '_poll_disable'), \
mock.patch.object(server, '_cancel_menu_timeout'), \
mock.patch.object(server, '_timer_cancel'), \
mock.patch.object(server, '_reset_proactive_log'):
server._handle_card_disconnect()
self.assertIsNone(ref.iccid)
self.assertFalse(server._CARD_CONNECTED)
finally:
server._server_ref = old_ref
server._CARD_CONNECTED = old_connected
if __name__ == '__main__':
unittest.main()