#!/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(' 槽-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)