Files
gd32e230f8_firmware_merge_tool/test_fw_pack.py
T
hulk 8af9264ae4 chore: 内部迭代号出清收尾——CLI/测试/README 注释标签改中性表述,删除旧 profile 名迁移映射
- gd32e230_firmware_tool.py / test_fw_pack.py / README.md:v5/dev_fact 等会话自管理标签改为"双固件 bootloader"中性表述;参考产物 *_UPDATE_V5.bin 更名 *_UPDATE.bin(含验收包内 1.0.18),测试引用同步
- config.py:删除 _RENAMED_PROFILE 旧名迁移映射(ceramixnano_mb_v51 仅存在数小时;残留旧名加载 profile 时报错提示重选)
- AGENTS.md:留档出清进度,并修正"GUI 旧配置自动迁移"的过时表述(迁移映射已删)
- 55 项回归全绿;fw_pack.py 侧标签清理已随 5a85f68 入库
2026-10-25 00:43:53 +08:00

729 lines
35 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. FACTORY 工厂镜像(BL+B+flag 无 A, 锚点擦除态) + 篡改检测;
8. 真机固件回归(CeramixNano 1.0.17/1.0.18/V1.0.12, 字节级复现
2026-10-22 验收包, 文件缺失时自动跳过);
9. 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.bin')
U18_REF = os.path.join(CAP, 'test', 'CeramixNano-MB_V1.0.18_APP_2026-10-21_UPDATE.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_Dual_FW')
def variant_profile(slot_a_k, slot_b_k, with_b=True):
raw = json.loads(json.dumps(fw_pack.BUILTIN_PROFILES['CeramixNano_MB_Dual_FW']))
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_Dual_FW']))
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_Dual_FW'], f)
p = fw_pack.load_profile(path)
self.assertEqual(p.name, 'CeramixNano_MB_Dual_FW')
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. FACTORY 工厂镜像 (BL + B 满槽 + flag, 无 A)
# ═══════════════════════════════════════════════════════════
class TestFactory(unittest.TestCase):
def setUp(self):
self.p = ceramix_profile()
self.bl = synth_bin(BL_BASE, 4044, rst_off=0xD00)
self.b_body = synth_bin(B_BASE, SLOT_B_SIZE - 8)
self.bp = fw_pack.build_b_pack(self.b_body, self.p)
def test_factory_layout_bytes(self):
"""BL + B 满槽 + flag; A 槽(含锚点)整体擦除态 → 上电判定链落 B"""
img = fw_pack.build_factory(self.p, self.bl, self.bp)
self.assertEqual(len(img), 64 * 1024)
self.assertEqual(img[:4044], self.bl)
self.assertTrue(all(b == 0xFF for b in img[4044:0xB000])) # BL 余量 + 整个 A 槽
self.assertEqual(img[0xAFFC:0xB000], b'\xff' * 4) # 锚点擦除态
self.assertEqual(img[0xB000:0xFC00], self.bp.data)
self.assertEqual(img[0xFFFC:0x10000], b'\xee\xee\x00\x00')
def test_verify_factory_all_pass(self):
img = fw_pack.build_factory(self.p, self.bl, self.bp)
checks = fw_pack.verify_factory_blob(img, self.p, self.bl, self.b_body)
bad = [(n, t) for n, ok, t in checks if not ok]
self.assertEqual(bad, [], checks)
self.assertGreaterEqual(len(checks), 10)
def test_verify_factory_detects_corruption(self):
img = bytearray(fw_pack.build_factory(self.p, self.bl, self.bp))
img[0xB000 + 100] ^= 0xFF # 破坏 B 体
checks = fw_pack.verify_factory_blob(bytes(img), self.p, self.bl,
self.b_body)
self.assertFalse(all(ok for _n, ok, _t in checks))
img2 = bytearray(fw_pack.build_factory(self.p, self.bl, self.bp))
img2[0xAFFC] = 0x14 # A 槽出现锚点痕迹
checks = fw_pack.verify_factory_blob(bytes(img2), self.p, self.bl,
self.b_body)
self.assertFalse(all(ok for _n, ok, _t in checks))
def test_requires_b_pack(self):
with self.assertRaises(fw_pack.FwPackError):
fw_pack.build_factory(self.p, self.bl, None)
def test_suggest_factory_name(self):
self.assertEqual(
fw_pack.suggest_factory_name(
r'x\CeramixNano-MB_V1.0.12_APP_2026-10-20.bin'),
'CeramixNano-MB_FACTORY_B-V1.0.12_2026-10-20.bin')
self.assertEqual(fw_pack.suggest_factory_name(r'x\app_b.bin'),
'app_b_FACTORY.bin')
# ═══════════════════════════════════════════════════════════
# 8. 真机回归: CeramixNano 双固件 (字节级复现验收包)
# ═══════════════════════════════════════════════════════════
@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_Dual_FW', A17, b_path=B12,
bl_path=bl_path, out_dir=td, progress=quiet())
upd = open(out.paths['update'], 'rb').read()
full = open(out.paths['full'], 'rb').read()
fac = open(out.paths['factory'], 'rb').read()
self.assertEqual(upd, open(U17_REF, 'rb').read())
self.assertEqual(full, self.ref_full)
# FACTORY = 参考 FULL 把 A 槽区(含锚点)整体置 0xFF
expect_fac = bytearray(self.ref_full)
expect_fac[0x2000:0xB000] = b'\xFF' * (0xB000 - 0x2000)
self.assertEqual(fac, bytes(expect_fac))
self.assertTrue(out.ok)
# 自动命名: FULL 与验收包一致, FACTORY 带 B 版本号
self.assertEqual(os.path.basename(out.paths['full']),
'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin')
self.assertEqual(os.path.basename(out.paths['factory']),
'CeramixNano-MB_FACTORY_B-V1.0.12_2026-10-20.bin')
# MD5 清单挂在 FULL, 包含全部产物与正确摘要
mani = open(out.paths['md5'], encoding='utf-8').read()
for key in ('update', 'full', 'factory'):
blob = open(out.paths[key], 'rb').read()
self.assertIn(hashlib.md5(blob).hexdigest(), mani)
self.assertIn('CeramixNano_MB_Dual_FW', mani)
self.assertIn('0xD30F0014', mani)
self.assertIn('DE C0 13 00 6F 18 FF FF', 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')
self.bl = os.path.join(self.dir, 'bl.bin')
open(self.a, 'wb').write(synth_bin(A_BASE, 19580))
open(self.b, 'wb').write(synth_bin(B_BASE, SLOT_B_SIZE))
open(self.bl, 'wb').write(synth_bin(BL_BASE, 4044, rst_off=0xD00))
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_a_FULL.bin', 'app_b_FACTORY.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)
fac = open(os.path.join(self.dir, 'app_b_FACTORY.bin'), 'rb').read()
self.assertEqual(fac[0x2000:0xB000], b'\xFF' * 0x9000) # A 槽擦除态
self.assertFalse([f for f in os.listdir(self.dir)
if f.endswith('_B.bin')]) # 不再单独出 B 包
self.assertIn('回读复核通过', out)
def test_v2_b_only_factory(self):
"""仅 B 输入 → 只出 *_FACTORY.bin(+MD5), 不拼 FULL"""
rc, out, err = run_cli(self.b, '-b', self.bl, '-o', self.dir)
self.assertEqual(rc, 0, err or out)
self.assertTrue(os.path.isfile(os.path.join(self.dir, 'app_b_FACTORY.bin')))
self.assertTrue(os.path.isfile(os.path.join(self.dir, 'app_b_FACTORY.bin.md5.txt')))
self.assertFalse([f for f in os.listdir(self.dir) if 'FULL' in f])
def test_v2_no_factory_flag(self):
rc, out, err = run_cli(self.a, self.b, '-o', self.dir, '--no-factory')
self.assertEqual(rc, 0, err or out)
self.assertFalse([f for f in os.listdir(self.dir) if 'FACTORY' 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())
expect_fac = bytearray(open(FULL_REF, 'rb').read())
expect_fac[0x2000:0xB000] = b'\xFF' * (0xB000 - 0x2000)
fac = open(os.path.join(td,
'CeramixNano-MB_FACTORY_B-V1.0.12_2026-10-20.bin'),
'rb').read()
self.assertEqual(fac, bytes(expect_fac))
self.assertIn('0xD30F0014', out)
self.assertIn('DE C0 13 00 6F 18 FF FF', out)
self.assertIn('FACTORY', out)
self.assertFalse([f for f in os.listdir(td) if f.endswith('_B.bin')])
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)