Files
gd32e230f8_firmware_merge_tool/test_fw_pack.py
T

649 lines
31 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
fw_pack / gd32e230_firmware_tool v2 回归测试套件
================================================================
覆盖 UPGRADE_PLAN_v2.md 第五节回归基准 + 自建边界用例:
1. CRC-16/XMODEM 测试向量;
2. profile 解析/校验(内置 + 变体 + 非法配置);
3. 向量表识别 A/B/BL 与显式冲突;
4. A 包整页扩展规则(恰整页 / 余量<4 / 余量==4 / 超槽);
5. B 包固定满槽 + 记录(满槽输入 / 小输入 / 超槽-8);
6. FULL 拼装 + 逐项字节校验 + 回读复核(含篡改检测);
7. 真机固件回归(CeramixNano 1.0.17/1.0.18/V1.0.12, 字节级复现
2026-10-22 验收包, 文件缺失时自动跳过);
8. CLI 冒烟(v2 / legacy / 错误路径)。
运行: python test_fw_pack.py [-v]
全部通过 = v2 升级验收通过。
"""
import hashlib
import json
import os
import struct
import subprocess
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fw_pack # noqa: E402
ROOT = os.path.dirname(os.path.abspath(__file__))
CLI = os.path.join(ROOT, 'gd32e230_firmware_tool.py')
A_BASE, B_BASE, BL_BASE = 0x08002000, 0x0800B000, 0x08000000
PAGE = 1024
SLOT_A_SIZE, SLOT_B_SIZE = 36 * PAGE, 19 * PAGE
# -- CeramixNano 真机固件(回归基准, 缺文件自动跳过) --
CAP = r'E:\Hulk-Coding\capsule_mb_camke_vscode\Build'
A17 = os.path.join(CAP, 'Release', 'CeramixNano-MB_V1.0.17_APP_2026-10-21.bin')
A18 = os.path.join(CAP, 'Release', 'CeramixNano-MB_V1.0.18_APP_2026-10-21.bin')
B12 = os.path.join(CAP, 'Release', 'CeramixNano-MB_V1.0.12_APP_2026-10-20.bin')
U17_REF = os.path.join(CAP, 'test', 'CeramixNano-MB_V1.0.17_APP_2026-10-21_UPDATE_V5.bin')
U18_REF = os.path.join(CAP, 'test', 'CeramixNano-MB_V1.0.18_APP_2026-10-21_UPDATE_V5.bin')
FULL_REF = os.path.join(CAP, 'test', 'acceptance_2026-10-22',
'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin')
HAVE_REAL = all(os.path.isfile(p) for p in (A17, A18, B12, U17_REF, FULL_REF))
LDC_APP = (r'E:\Hulk-Coding\ldc1612_cmake_vscode\Build\codex-bootloader-test'
r'\LDC1612_V1.0.1_APP_2026-09-26.bin')
LDC_UPD_REF = os.path.join(ROOT, 'LDC1612_V1.0.1_APP_2026-09-26_update.bin')
# ═══════════════════════════════════════════════════════════
# 测试辅助: 独立参考实现 + 合成固件
# ═══════════════════════════════════════════════════════════
def ref_crc16(data):
"""独立抄写的 XMODEM CRC (与 fw_pack 互为交叉验证)"""
crc = 0x0000
for byte in data:
crc ^= byte << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) if crc & 0x8000 else (crc << 1)
crc &= 0xFFFF
return crc
def ref_a_pack(body):
"""按规格直译的 A 包参考实现 (不经 fw_pack 代码路径)"""
n = (len(body) + PAGE - 1) // PAGE
if len(body) % PAGE == 0 or n * PAGE - len(body) < 4:
n += 1
buf = bytearray(body) + b'\xFF' * (n * PAGE - len(body))
crc = ref_crc16(bytes(buf[:n * PAGE - 4]))
struct.pack_into('<HH', buf, n * PAGE - 4, n, crc)
return bytes(buf), n, crc
def synth_bin(base, size, rst_off=0x101):
"""合成一个向量表合法的固件 bin (SP=0x20000800, 复位向量=base+rst_off|1)"""
buf = bytearray(size)
struct.pack_into('<II', buf, 0, 0x20000800, (base + rst_off) | 1)
for i in range(8, size):
buf[i] = (i * 7 + 13) & 0xFF
return bytes(buf)
def ceramix_profile():
return fw_pack.load_profile('ceramixnano_mb_v51')
def variant_profile(slot_a_k, slot_b_k, with_b=True):
raw = json.loads(json.dumps(fw_pack.BUILTIN_PROFILES['ceramixnano_mb_v51']))
raw['slot_a']['size'] = f'{slot_a_k}K'
if with_b:
raw['slot_b']['size'] = f'{slot_b_k}K'
raw['slot_b']['addr'] = '0x%08X' % (A_BASE + slot_a_k * 1024) # B 紧跟 A
else:
raw['slot_b'] = None
return fw_pack.Profile(raw)
def run_cli(*argv, cwd=None):
env = dict(os.environ, PYTHONIOENCODING='utf-8', PYTHONUTF8='1')
p = subprocess.run([sys.executable, CLI, *argv], capture_output=True,
text=True, encoding='utf-8', errors='replace',
cwd=cwd or ROOT, env=env, timeout=120)
return p.returncode, p.stdout, p.stderr
def quiet():
return lambda *_a: None
# ═══════════════════════════════════════════════════════════
# 1. CRC 自检
# ═══════════════════════════════════════════════════════════
class TestCRC(unittest.TestCase):
def test_vectors(self):
self.assertEqual(fw_pack.crc16_xmodem(b'123456789'), 0x31C3)
self.assertEqual(ref_crc16(b'123456789'), 0x31C3)
self.assertEqual(fw_pack.crc16_xmodem(b''), 0x0000)
self.assertEqual(fw_pack.crc16_xmodem(b'\xFF' * 20476),
ref_crc16(b'\xFF' * 20476))
fw_pack.self_test() # 不抛即通过
def test_selftest_detects_broken_impl(self):
orig = fw_pack.crc16_xmodem
fw_pack.crc16_xmodem = lambda d: 0
try:
with self.assertRaises(fw_pack.FwPackError):
fw_pack.self_test()
finally:
fw_pack.crc16_xmodem = orig
# ═══════════════════════════════════════════════════════════
# 2. Profile 解析与校验
# ═══════════════════════════════════════════════════════════
class TestProfile(unittest.TestCase):
def test_builtin_ceramixnano(self):
p = ceramix_profile()
self.assertEqual((p.flash_base, p.flash_size, p.page_size),
(0x08000000, 64 * 1024, 1024))
self.assertEqual((p.bl_addr, p.bl_size), (0x08000000, 8 * 1024))
self.assertEqual((p.slot_a.addr, p.slot_a.size), (0x08002000, 36 * 1024))
self.assertEqual(p.slot_a.anchor_addr, 0x0800AFFC)
self.assertEqual((p.slot_b.addr, p.slot_b.size), (0x0800B000, 19 * 1024))
self.assertTrue(p.slot_b.fixed_slot)
self.assertEqual(p.slot_b.record_addr, 0x0800FBF8)
self.assertEqual(p.slot_b.record_area_len(1024), 8)
self.assertEqual((p.flag_addr, p.flag_value), (0x0800FFFC, 0xEEEE))
def test_builtin_single_slot(self):
p = fw_pack.load_profile('f8_single_slot')
self.assertIsNone(p.slot_b)
self.assertEqual(p.slot_a.size, 55 * 1024)
def test_variants_b8k_b28k_nob(self):
variant_profile(47, 8) # 保底槽 8K
variant_profile(27, 28) # 保底槽 28K
variant_profile(55, None, with_b=False) # 无保底槽
# 各变体都能完成一次 A 包打包
for a_k, b_k, with_b in ((47, 8, True), (27, 28, True), (55, None, False)):
p = variant_profile(a_k, b_k, with_b)
ap = fw_pack.build_a_pack(synth_bin(0x08002000, 5000), p)
self.assertEqual(ap.pages, 5)
def test_parse_num(self):
self.assertEqual(fw_pack.parse_num('0x08002000'), 0x08002000)
self.assertEqual(fw_pack.parse_num('64K'), 65536)
self.assertEqual(fw_pack.parse_num('19k'), 19456)
self.assertEqual(fw_pack.parse_num('1024'), 1024)
self.assertEqual(fw_pack.parse_num(2048), 2048)
with self.assertRaises(fw_pack.FwPackError):
fw_pack.parse_num('abc')
with self.assertRaises(fw_pack.FwPackError):
fw_pack.parse_num('')
def _invalid(self, mutate, needle):
raw = json.loads(json.dumps(fw_pack.BUILTIN_PROFILES['ceramixnano_mb_v51']))
mutate(raw)
with self.assertRaises(fw_pack.FwPackError) as cm:
fw_pack.Profile(raw)
self.assertIn(needle, str(cm.exception))
def test_invalid_layouts(self):
self._invalid(lambda r: r['slot_a'].__setitem__('size', '37K'),
'重叠') # A 槽压到 B 槽
self._invalid(lambda r: r['slot_a'].__setitem__('size', '35K'),
'总和') # 分区和不等于 flash
self._invalid(lambda r: r['slot_a'].__setitem__('addr', '0x08002040'),
'页对齐')
self._invalid(lambda r: r['flag'].__setitem__('addr', '0x0800F000'),
'末页')
self._invalid(lambda r: r.__setitem__('crc', 'crc32'), 'xmodem')
self._invalid(lambda r: r['slot_b'].__setitem__('record_addr', 'slot_end-4'),
'记录区') # 6B 记录塞不进 4B 区
self._invalid(lambda r: r['slot_a'].__setitem__('record', '{pages16@+0}'),
'crc')
def test_load_profile_path_and_errors(self):
with tempfile.TemporaryDirectory() as td:
path = os.path.join(td, 'p.json')
with open(path, 'w', encoding='utf-8') as f:
json.dump(fw_pack.BUILTIN_PROFILES['ceramixnano_mb_v51'], f)
p = fw_pack.load_profile(path)
self.assertEqual(p.name, 'ceramixnano_mb_v51')
bad = os.path.join(td, 'bad.json')
with open(bad, 'w', encoding='utf-8') as f:
f.write('{ not json')
with self.assertRaises(fw_pack.FwPackError):
fw_pack.load_profile(bad)
with self.assertRaises(fw_pack.FwPackError):
fw_pack.load_profile('no_such_profile')
# ═══════════════════════════════════════════════════════════
# 3. 向量表识别与硬门槛
# ═══════════════════════════════════════════════════════════
class TestDetect(unittest.TestCase):
def setUp(self):
self.p = ceramix_profile()
def test_detect_a_b(self):
self.assertEqual(fw_pack.detect_slot(synth_bin(A_BASE, 1000), self.p), 'a')
self.assertEqual(fw_pack.detect_slot(synth_bin(B_BASE, 1000), self.p), 'b')
def test_bl_input_rejected_with_hint(self):
with self.assertRaises(fw_pack.FwPackError) as cm:
fw_pack.detect_slot(synth_bin(BL_BASE, 1000), self.p)
self.assertIn('bootloader', str(cm.exception))
def test_bad_vector(self):
bad_sp = bytearray(synth_bin(A_BASE, 1000))
struct.pack_into('<I', bad_sp, 0, 0x30000000) # SP 不在 SRAM
with self.assertRaises(fw_pack.FwPackError):
fw_pack.detect_slot(bytes(bad_sp), self.p)
bad_thumb = bytearray(synth_bin(A_BASE, 1000))
struct.pack_into('<I', bad_thumb, 4, A_BASE + 0x100) # bit0=0, 非 Thumb
with self.assertRaises(fw_pack.FwPackError):
fw_pack.detect_slot(bytes(bad_thumb), self.p)
tiny = struct.pack('<II', 0x20000800, A_BASE | 1) # 只有向量表也够识别
self.assertEqual(fw_pack.detect_slot(tiny, self.p), 'a')
with self.assertRaises(fw_pack.FwPackError):
fw_pack.detect_slot(b'\x00\x01', self.p) # 太小
def test_validate_app_bin(self):
fw_pack.validate_app_bin(synth_bin(B_BASE, 1000), self.p, 'b')
with self.assertRaises(fw_pack.FwPackError):
fw_pack.validate_app_bin(synth_bin(B_BASE, 1000), self.p, 'a')
no_b = fw_pack.load_profile('f8_single_slot')
with self.assertRaises(fw_pack.FwPackError):
fw_pack.validate_app_bin(synth_bin(B_BASE, 1000), no_b, 'b')
# ═══════════════════════════════════════════════════════════
# 4. A 包: 整页扩展规则
# ═══════════════════════════════════════════════════════════
class TestAPack(unittest.TestCase):
def setUp(self):
self.p = ceramix_profile()
def _body(self, size):
return synth_bin(A_BASE, size)
def test_against_reference_impl(self):
for size in (8, 1024, 1025, 5000, 20476, 20477, 20480, 20481, 36860):
body = self._body(size)
got = fw_pack.build_a_pack(body, self.p)
exp, exp_n, exp_crc = ref_a_pack(body)
self.assertEqual(got.data, exp, f'size={size}')
self.assertEqual((got.pages, got.crc), (exp_n, exp_crc))
self.assertEqual(len(got.data), exp_n * PAGE)
self.assertEqual(got.trailer_value,
struct.unpack('<I', exp[-4:])[0])
def test_exact_page_expands(self):
"""恰整页(body=20480B) → 扩展为 21 页, trailer pages=21"""
ap = fw_pack.build_a_pack(self._body(20480), self.p)
self.assertEqual(ap.pages, 21)
self.assertEqual(len(ap.data), 21 * PAGE)
self.assertEqual(ap.data[-4:], struct.pack('<HH', 21, ap.crc))
# CRC 覆盖 21 页去掉末 4B (含 0xFF 填充)
self.assertEqual(ap.crc, ref_crc16(ap.data[:21 * PAGE - 4]))
def test_remainder_lt_record_expands(self):
"""余量<4B(body=20477B, 余量3) → 扩展为 21 页"""
ap = fw_pack.build_a_pack(self._body(20477), self.p)
self.assertEqual(ap.pages, 21)
self.assertEqual(ap.crc, ref_crc16(ap.data[:21 * PAGE - 4]))
def test_remainder_eq_record_no_expand(self):
"""余量恰好=4B(body=20476B) → 不扩展, 20 页"""
ap = fw_pack.build_a_pack(self._body(20476), self.p)
self.assertEqual(ap.pages, 20)
self.assertEqual(len(ap.data), 20480)
def test_overflow_rejected(self):
"""超槽: 恰 36 整页(36864B)扩 1 页即超 36K → 报错; 36861B 余量3扩页也超"""
for size in (36861, 36864, 36865, 40000):
with self.assertRaises(fw_pack.FwPackError, msg=f'size={size}'):
fw_pack.build_a_pack(self._body(size), self.p)
def test_max_fit(self):
"""36860B: 余量4, 36 页正好放下"""
ap = fw_pack.build_a_pack(self._body(36860), self.p)
self.assertEqual(ap.pages, 36)
self.assertEqual(len(ap.data), SLOT_A_SIZE)
def test_empty_rejected(self):
with self.assertRaises(fw_pack.FwPackError):
fw_pack.build_a_pack(b'', self.p)
# ═══════════════════════════════════════════════════════════
# 5. B 包: 固定满槽 + 记录
# ═══════════════════════════════════════════════════════════
class TestBPack(unittest.TestCase):
def setUp(self):
self.p = ceramix_profile()
def test_full_slot_input_record_rewritten(self):
"""满槽输入(19456B, 尾部为旧格式 magic+FF) → 末 8B 重写为新记录"""
body = bytearray(synth_bin(B_BASE, SLOT_B_SIZE - 8))
body += b'\xde\xc0' + b'\xff' * 6 # 旧格式尾部
bp = fw_pack.build_b_pack(bytes(body), self.p)
self.assertEqual(len(bp.data), SLOT_B_SIZE)
self.assertEqual(bp.pages, 19)
self.assertEqual(bp.crc, ref_crc16(bp.data[:SLOT_B_SIZE - 8]))
rec = struct.pack('<HHH', 0xC0DE, 19, bp.crc) + b'\xff\xff'
self.assertEqual(bp.record_bytes, rec[:6])
self.assertEqual(bp.data[-8:], rec)
self.assertEqual(bp.record_addr, 0x0800FBF8)
# 记录之外的槽内容与输入一致
self.assertEqual(bp.data[:SLOT_B_SIZE - 8], bytes(body[:SLOT_B_SIZE - 8]))
def test_small_input_padded(self):
body = synth_bin(B_BASE, 10000)
bp = fw_pack.build_b_pack(body, self.p)
self.assertEqual(len(bp.data), SLOT_B_SIZE)
self.assertEqual(bp.data[:10000], body)
self.assertEqual(bp.data[10000:SLOT_B_SIZE - 8],
b'\xff' * (SLOT_B_SIZE - 8 - 10000))
self.assertEqual(bp.pages, 19)
self.assertEqual(bp.crc, ref_crc16(bp.data[:SLOT_B_SIZE - 8]))
def test_overflow_rejected(self):
"""B 固件 > 槽-8B 且非满槽镜像 → 报错(固定槽不可扩展)"""
for size in (SLOT_B_SIZE - 7, SLOT_B_SIZE - 1):
with self.assertRaises(fw_pack.FwPackError, msg=f'size={size}'):
fw_pack.build_b_pack(synth_bin(B_BASE, size), self.p)
def test_exact_body_fits(self):
"""恰好 19448B(槽-8B) → 正常"""
bp = fw_pack.build_b_pack(synth_bin(B_BASE, SLOT_B_SIZE - 8), self.p)
self.assertEqual(bp.pages, 19)
def test_no_slot_b_profile(self):
with self.assertRaises(fw_pack.FwPackError):
fw_pack.build_b_pack(synth_bin(B_BASE, 1000),
fw_pack.load_profile('f8_single_slot'))
# ═══════════════════════════════════════════════════════════
# 6. FULL 拼装 / 复核 / 篡改检测
# ═══════════════════════════════════════════════════════════
class TestFull(unittest.TestCase):
def setUp(self):
self.p = ceramix_profile()
self.a_body = synth_bin(A_BASE, 5000)
self.ap = fw_pack.build_a_pack(self.a_body, self.p)
self.b_body = synth_bin(B_BASE, SLOT_B_SIZE - 8)
self.bp = fw_pack.build_b_pack(self.b_body, self.p)
self.bl = synth_bin(BL_BASE, 4044, rst_off=0xD00)
def test_full_layout_bytes(self):
img = fw_pack.build_full(self.p, self.bl, self.ap, self.bp)
self.assertEqual(len(img), 64 * 1024)
base = 0x08000000
self.assertEqual(img[:4044], self.bl) # BL
self.assertTrue(all(b == 0xFF for b in img[4044:0x2000])) # BL 余量
off = A_BASE - base
self.assertEqual(img[off:off + len(self.ap.data)], self.ap.data)
self.assertEqual(img[0xAFFC:0xB000],
self.ap.record_bytes[:4]) # 槽尾锚点
self.assertTrue(all(b == 0xFF for b in
img[off + len(self.ap.data):0xAFFC])) # 空隙
self.assertEqual(img[0xB000:0xFC00], self.bp.data) # B 满槽
self.assertEqual(img[0xFFFC:0x10000], b'\xee\xee\x00\x00') # flag
self.assertTrue(all(b == 0xFF for b in img[0xFC00:0xFFFC]))
def test_full_without_bl_or_b(self):
img = fw_pack.build_full(self.p, None, self.ap, None)
self.assertTrue(all(b == 0xFF for b in img[:0x2000]))
self.assertTrue(all(b == 0xFF for b in img[0xB000:0xFC00]))
checks = fw_pack.verify_full_blob(img, self.p, None, self.a_body, None)
self.assertTrue(all(ok for _n, ok, _t in checks), checks)
def test_verify_full_all_pass(self):
img = fw_pack.build_full(self.p, self.bl, self.ap, self.bp)
checks = fw_pack.verify_full_blob(img, self.p, self.bl,
self.a_body, self.b_body)
self.assertGreaterEqual(len(checks), 17) # 规格要求 17 项
bad = [(n, t) for n, ok, t in checks if not ok]
self.assertEqual(bad, [], checks)
def test_verify_full_detects_corruption(self):
img = bytearray(fw_pack.build_full(self.p, self.bl, self.ap, self.bp))
img[0x2000 + 100] ^= 0xFF # 破坏 A 体
checks = fw_pack.verify_full_blob(bytes(img), self.p, self.bl,
self.a_body, self.b_body)
self.assertFalse(all(ok for _n, ok, _t in checks))
img2 = bytearray(fw_pack.build_full(self.p, self.bl, self.ap, self.bp))
img2[0xFFFC] = 0x00 # 破坏 flag
checks = fw_pack.verify_full_blob(bytes(img2), self.p, self.bl,
self.a_body, self.b_body)
self.assertFalse(all(ok for _n, ok, _t in checks))
def test_verify_blobs(self):
fw_pack.verify_a_blob(self.ap.data, self.p) # 正常不抛
fw_pack.verify_b_blob(self.bp.data, self.p)
with self.assertRaises(fw_pack.FwPackError):
fw_pack.verify_a_blob(self.ap.data[:-1] + b'\x00', self.p)
with self.assertRaises(fw_pack.FwPackError):
fw_pack.verify_b_blob(self.bp.data[:-1], self.p)
def test_bl_too_large(self):
with self.assertRaises(fw_pack.FwPackError):
fw_pack.build_full(self.p, synth_bin(BL_BASE, 8 * 1024 + 1),
self.ap, self.bp)
# ═══════════════════════════════════════════════════════════
# 7. 真机回归: CeramixNano v5.1 (字节级复现验收包)
# ═══════════════════════════════════════════════════════════
@unittest.skipUnless(HAVE_REAL, '需要 CeramixNano 固件(见 test_fw_pack.py 头部路径)')
class TestRegressionCeramixNano(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.p = ceramix_profile()
cls.a17 = open(A17, 'rb').read()
cls.a18 = open(A18, 'rb').read()
cls.b12 = open(B12, 'rb').read()
cls.ref_full = open(FULL_REF, 'rb').read()
cls.bl = cls.ref_full[:0x2000] # BL 区(4044B + 0xFF)
def test_1017_update(self):
"""1.0.17: 20 页/20480B, trailer=锚点=0xD30F0014, 与验收包逐字节一致"""
ap = fw_pack.build_a_pack(self.a17, self.p)
self.assertEqual((len(self.a17), ap.pages, len(ap.data)), (19580, 20, 20480))
self.assertEqual(ap.trailer_value, 0xD30F0014)
self.assertEqual(ap.crc, 0xD30F)
self.assertEqual(ap.data, open(U17_REF, 'rb').read())
def test_1018_update(self):
"""1.0.18: 20 页, trailer=0x6F380014 (CRC 0x6F38)"""
ap = fw_pack.build_a_pack(self.a18, self.p)
self.assertEqual((ap.pages, ap.crc, ap.trailer_value),
(20, 0x6F38, 0x6F380014))
if os.path.isfile(U18_REF):
self.assertEqual(ap.data, open(U18_REF, 'rb').read())
def test_b_record(self):
"""V1.0.12: 满槽 19456B, 记录 DE C0 13 00 6F 18 FF FF (pages=19, CRC=0x186F)"""
bp = fw_pack.build_b_pack(self.b12, self.p)
self.assertEqual(len(self.b12), 19456)
self.assertEqual(bp.data[-8:], bytes.fromhex('de c0 13 00 6f 18 ff ff'))
self.assertEqual((bp.pages, bp.crc), (19, 0x186F))
self.assertEqual(bp.crc, ref_crc16(bp.data[:19448]))
def test_full_byte_identical(self):
"""FULL 65,536B 与 2026-10-22 验收包逐字节一致 (MD5 f704b837...)"""
ap = fw_pack.build_a_pack(self.a17, self.p)
bp = fw_pack.build_b_pack(self.b12, self.p)
img = fw_pack.build_full(self.p, self.bl, ap, bp)
self.assertEqual(len(img), 65536)
self.assertEqual(hashlib.md5(img).hexdigest(),
'f704b8374ccaff7a0f0f385c59897577')
self.assertEqual(img, self.ref_full)
def test_detect_real_bins(self):
self.assertEqual(fw_pack.detect_slot(self.a17, self.p), 'a')
self.assertEqual(fw_pack.detect_slot(self.a18, self.p), 'a')
self.assertEqual(fw_pack.detect_slot(self.b12, self.p), 'b')
def test_pack_all_pipeline(self):
"""pack_all 一站式: 产物/MD5 清单/复核全链路 (临时目录)"""
with tempfile.TemporaryDirectory() as td:
bl_path = os.path.join(td, 'bl.bin')
open(bl_path, 'wb').write(self.bl)
out = fw_pack.pack_all('ceramixnano_mb_v51', A17, b_path=B12,
bl_path=bl_path, out_dir=td, progress=quiet())
upd = open(out.paths['update'], 'rb').read()
bpk = open(out.paths['b'], 'rb').read()
full = open(out.paths['full'], 'rb').read()
self.assertEqual(upd, open(U17_REF, 'rb').read())
self.assertEqual(bpk[-8:], bytes.fromhex('de c0 13 00 6f 18 ff ff'))
self.assertEqual(full, self.ref_full)
self.assertTrue(out.ok)
# FULL 自动命名与验收包一致
self.assertEqual(os.path.basename(out.paths['full']),
'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin')
# MD5 清单包含全部产物与正确摘要
mani = open(out.paths['md5'], encoding='utf-8').read()
for key in ('update', 'b', 'full'):
blob = open(out.paths[key], 'rb').read()
self.assertIn(hashlib.md5(blob).hexdigest(), mani)
self.assertIn('ceramixnano_mb_v51', mani)
self.assertIn('0xD30F0014', mani)
def test_suggest_full_name(self):
self.assertEqual(
fw_pack.suggest_full_name(
r'x\CeramixNano-MB_V1.0.17_APP_2026-10-21.bin',
r'y\CeramixNano-MB_V1.0.12_APP_2026-10-20.bin'),
'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin')
self.assertEqual(fw_pack.suggest_full_name(r'x\app.bin', None),
'app_FULL.bin')
# ═══════════════════════════════════════════════════════════
# 8. CLI 冒烟: v2 / legacy / 错误路径
# ═══════════════════════════════════════════════════════════
class TestCLI(unittest.TestCase):
def setUp(self):
self.td = tempfile.TemporaryDirectory()
self.dir = self.td.name
self.a = os.path.join(self.dir, 'app_a.bin')
self.b = os.path.join(self.dir, 'app_b.bin')
open(self.a, 'wb').write(synth_bin(A_BASE, 19580))
open(self.b, 'wb').write(synth_bin(B_BASE, SLOT_B_SIZE))
def tearDown(self):
self.td.cleanup()
def test_v2_full_pipeline(self):
rc, out, err = run_cli(self.a, self.b, '-o', self.dir)
self.assertEqual(rc, 0, err or out)
for name in ('app_a_UPDATE.bin', 'app_b_B.bin', 'app_a_FULL.bin'):
self.assertTrue(os.path.isfile(os.path.join(self.dir, name)), name)
self.assertTrue(os.path.isfile(
os.path.join(self.dir, 'app_a_FULL.bin.md5.txt')))
full = open(os.path.join(self.dir, 'app_a_FULL.bin'), 'rb').read()
self.assertEqual(len(full), 65536)
self.assertIn('回读复核通过', out)
def test_v2_b_only_no_full(self):
"""仅 B 输入 → 只出 *_B.bin, 不拼 FULL (无 A 运行槽的整片镜像不可启动)"""
rc, out, err = run_cli(self.b, '-o', self.dir)
self.assertEqual(rc, 0, err or out)
self.assertTrue(os.path.isfile(os.path.join(self.dir, 'app_b_B.bin')))
self.assertFalse([f for f in os.listdir(self.dir) if 'FULL' in f])
def test_v2_explicit_conflict(self):
rc, out, err = run_cli('--b', self.a) # A 固件显式塞给 B 槽
self.assertNotEqual(rc, 0)
self.assertIn('复位向量', err + out)
def test_v2_positional_and_explicit_mix(self):
rc, _out, err = run_cli(self.a, '--b', self.b)
self.assertNotEqual(rc, 0)
self.assertIn('不能混用', err)
def test_v2_bl_region_input_rejected(self):
bl_like = os.path.join(self.dir, 'bl_like.bin')
open(bl_like, 'wb').write(synth_bin(BL_BASE, 4044, rst_off=0xD00))
rc, _out, err = run_cli(bl_like)
self.assertNotEqual(rc, 0)
self.assertIn('bootloader', err)
def test_v2_output_must_be_dir(self):
rc, _out, err = run_cli(self.a, '-o', os.path.join(self.dir, 'x.bin'))
self.assertNotEqual(rc, 0)
self.assertIn('输出目录', err)
def test_v2_duplicate_slot(self):
a2 = os.path.join(self.dir, 'app_a2.bin')
open(a2, 'wb').write(synth_bin(A_BASE, 9000))
rc, _out, err = run_cli(self.a, a2)
self.assertNotEqual(rc, 0)
self.assertIn('只能有一个输入', err)
@unittest.skipUnless(HAVE_REAL, '需要 CeramixNano 固件')
def test_v2_real_firmware(self):
with tempfile.TemporaryDirectory() as td:
bl_path = os.path.join(td, 'bl.bin')
open(bl_path, 'wb').write(open(FULL_REF, 'rb').read()[:0x2000])
rc, out, err = run_cli(A17, B12, '-b', bl_path, '-o', td)
self.assertEqual(rc, 0, err or out)
full = open(os.path.join(
td, 'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin'),
'rb').read()
self.assertEqual(full, open(FULL_REF, 'rb').read())
self.assertIn('0xD30F0014', out)
self.assertIn('DE C0 13 00 6F 18 FF FF', out)
def test_legacy_fill(self):
out = os.path.join(self.dir, 'legacy_update.bin')
rc, _o, err = run_cli('-f', self.a, '-o', out)
self.assertEqual(rc, 0, err)
blob = open(out, 'rb').read()
self.assertEqual(len(blob), 19580 + 900) # 纯 0xFF 补页, 无 trailer
self.assertEqual(blob[:19580], synth_bin(A_BASE, 19580))
self.assertEqual(blob[19580:], b'\xff' * 900)
def test_legacy_merge(self):
bl = os.path.join(self.dir, 'bl.bin')
open(bl, 'wb').write(synth_bin(BL_BASE, 3512, rst_off=0xD1C))
out = os.path.join(self.dir, 'legacy_bl.bin')
rc, _o, err = run_cli('-m', self.a, '-b', bl, '-o', out)
self.assertEqual(rc, 0, err)
blob = open(out, 'rb').read()
self.assertEqual(len(blob), 65536)
self.assertEqual(blob[:3512], synth_bin(BL_BASE, 3512, rst_off=0xD1C))
self.assertEqual(blob[0x2000:0x2000 + 19580], synth_bin(A_BASE, 19580))
self.assertEqual(blob[0xFFFC:], b'\xee\xee\x00\x00')
self.assertTrue(all(x == 0xFF for x in blob[0xF800:0xFC00])) # 校准页
def test_legacy_args_conflict(self):
rc, _o, err = run_cli('-f', self.a, '--profile', 'f8_single_slot')
self.assertNotEqual(rc, 0)
@unittest.skipUnless(os.path.isfile(LDC_APP) and os.path.isfile(LDC_UPD_REF),
'需要 LDC1612 原始 APP bin')
def test_legacy_ldc1612_reference(self):
"""对存量 LDC1612 包复现: -f 输出与 2026-09-26 的 update.bin 逐字节一致"""
with tempfile.TemporaryDirectory() as td:
out = os.path.join(td, 'u.bin')
rc, _o, err = run_cli('-f', LDC_APP, '-o', out)
self.assertEqual(rc, 0, err)
self.assertEqual(open(out, 'rb').read(), open(LDC_UPD_REF, 'rb').read())
if __name__ == '__main__':
print(f'fw_pack v2 回归测试 | 真机固件: {"可用" if HAVE_REAL else "缺失(跳过)"}')
unittest.main(verbosity=2)