v1.6.0 修复 PinMAP→PinList 上方引脚丢失 + 双向模板样式 + QFN60 端到端验证
F013: Code/src/pinmap_parser.py 增加 Top 边自动布局检测 F014/F015: 双向模板样式确认 F016/F017: 新增 5 个 QFN60 端到端测试
This commit is contained in:
@@ -28,6 +28,75 @@ def _try_int(value: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _count_numeric(values: list[str]) -> int:
|
||||
"""统计列表中可解析为整数的元素个数。"""
|
||||
return sum(1 for v in values if _try_int(v) is not None)
|
||||
|
||||
|
||||
def _detect_top_layout(cells: dict[tuple[int, int], str],
|
||||
min_row: int, min_col: int, max_col: int) -> tuple[int, int]:
|
||||
"""
|
||||
检测上边 Name 和 Number 的相对位置。
|
||||
|
||||
返回 (top_name_row, top_number_row) 元组。
|
||||
|
||||
布局 A(v1.5.5 默认):Name 在 row 0,Number 在 row 1
|
||||
布局 B(用户真实):Number 在 row 1,Name 在 row 2
|
||||
|
||||
A1 (0,0) 总是封装信息,不属于 Name/Number 数据。
|
||||
通过扫描候选行对的特征判断:数字多的行 = Number 行,文本多的行 = Name 行。
|
||||
仅扫描中间列(排除 min_col 和 max_col),因为左右边缘列可能包含左右边的数据。
|
||||
"""
|
||||
def _is_number_row(row: int) -> bool | None:
|
||||
"""判断某行是否为 Number 行。返回 True/False,无数据则返回 None。"""
|
||||
values = []
|
||||
# 仅扫描中间列,排除左右边缘
|
||||
for c in range(min_col + 1, max_col):
|
||||
v = cells.get((row, c), "")
|
||||
if v and str(v).strip():
|
||||
values.append(str(v).strip())
|
||||
# 回退:如果中间列无数据,扫描全部列
|
||||
if not values:
|
||||
for c in range(min_col, max_col + 1):
|
||||
v = cells.get((row, c), "")
|
||||
if v and str(v).strip():
|
||||
values.append(str(v).strip())
|
||||
if not values:
|
||||
return None
|
||||
numeric = _count_numeric(values)
|
||||
return numeric >= len(values) * 0.7
|
||||
|
||||
def _has_data(row: int) -> bool:
|
||||
"""检查指定行在中间列是否有数据。"""
|
||||
for c in range(min_col + 1, max_col):
|
||||
v = cells.get((row, c), "")
|
||||
if v and str(v).strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
row0_is_num = _is_number_row(0)
|
||||
row1_is_num = _is_number_row(1)
|
||||
row2_is_num = _is_number_row(2)
|
||||
row0_has_data = _has_data(0)
|
||||
|
||||
# 布局 A(v1.5.5 默认):Name 在 row 0(与 A1 同行),Number 在 row 1
|
||||
# → row 0 有数据且非数字,row 1 全是数字
|
||||
if row0_has_data and row0_is_num is False and row1_is_num is True:
|
||||
return (0, 1)
|
||||
|
||||
# 布局 B(用户真实):Number 在 row 1,Name 在 row 2
|
||||
# → row 0 无数据(仅 A1),row 1 全是数字,row 2 全是非数字
|
||||
if not row0_has_data and row1_is_num is True and row2_is_num is False:
|
||||
return (2, 1)
|
||||
|
||||
# 回退:如果行 0 主要是数字,行 1 不是数字
|
||||
if row0_is_num is True and row1_is_num is not True:
|
||||
return (1, 0)
|
||||
|
||||
# 默认回退:假设布局 A(v1.5.5 默认行为)
|
||||
return (0, 1)
|
||||
|
||||
|
||||
def parse_pinmap(cells: dict[tuple[int, int], str]) -> PinMAP:
|
||||
"""Parse a PinMAP from a cell dictionary and return a PinMAP object.
|
||||
|
||||
@@ -84,16 +153,25 @@ def parse_pinmap(cells: dict[tuple[int, int], str]) -> PinMAP:
|
||||
if not package_info or not str(package_info).strip():
|
||||
raise StructureError("A1 单元格为空,缺少封装信息")
|
||||
|
||||
# ── Step 3: build name lookup (v1.5.5 layout) ──────────────
|
||||
# ── Step 3: build name lookup ───────────────────────────────
|
||||
# For each edge, pin names live in the cell *adjacent inward*
|
||||
# from the boundary cell that holds the pin number.
|
||||
#
|
||||
# v1.5.5 layout:
|
||||
# left : number at (r, min_col), name at (r, min_col+1)
|
||||
# bottom : number at (max_row, c), name at (max_row-1, c)
|
||||
# right : number at (r, max_col), name at (r, max_col-1)
|
||||
# top : number at (min_row+1, c), name at (min_row, c)
|
||||
# No corner exceptions — top names are all one row above Numbers
|
||||
# top : layout-dependent (auto-detected below)
|
||||
|
||||
# ── Top edge layout auto-detection ───────────────────────────
|
||||
# 检测上边 Name 和 Number 的相对位置。
|
||||
# 布局 A(v1.5.5 默认):Name 在 row 0,Number 在 row 1
|
||||
# 布局 B(用户真实):Number 在 row 1,Name 在 row 2
|
||||
#
|
||||
# A1 在 (0,0) 是封装信息,不参与 Name/Number 的判断。
|
||||
# 检测基于行内容特征(数字 vs 文本),参考 min_col/max_col 确定扫描列范围。
|
||||
top_name_row, top_number_row = _detect_top_layout(
|
||||
cells, min_row=min_row, min_col=min_col, max_col=max_col
|
||||
)
|
||||
|
||||
name_map: dict[tuple[int, int], str] = {}
|
||||
|
||||
@@ -115,14 +193,13 @@ def parse_pinmap(cells: dict[tuple[int, int], str]) -> PinMAP:
|
||||
if name and str(name).strip():
|
||||
name_map[(r, max_col)] = str(name).strip()
|
||||
|
||||
# top edge names at (min_row, c) — one row ABOVE the Number row.
|
||||
# v1.5.5: 上边 Name 在 (min_row, c),Number 在 (min_row+1, c)。
|
||||
# top edge names: detected layout (top_name_row, top_number_row)
|
||||
# name_map key 是 Number 单元格坐标,value 是 Name 字符串。
|
||||
for c in range(min_col, max_col + 1):
|
||||
name = cells.get((min_row, c), "")
|
||||
name = cells.get((top_name_row, c), "")
|
||||
if name and str(name).strip() and _try_int(name) is None:
|
||||
name_map[(min_row + 1, c)] = str(name).strip()
|
||||
# No corner exceptions needed — top names are all on min_row
|
||||
name_map[(top_number_row, c)] = str(name).strip()
|
||||
# No corner exceptions needed — top names are all on one row
|
||||
|
||||
# ── Step 4: walk edges counter-clockwise (v1.3 formula) ──────
|
||||
# Each edge independently includes its endpoints (corners).
|
||||
@@ -165,9 +242,9 @@ def parse_pinmap(cells: dict[tuple[int, int], str]) -> PinMAP:
|
||||
for r in range(max_row, min_row - 1, -1):
|
||||
_add_pin(r, max_col, "right", max_row - r)
|
||||
|
||||
# 4d. Top edge: right → left (Numbers at min_row+1 row, Names at min_row)
|
||||
# 4d. Top edge: right → left (Numbers at top_number_row, Names at top_name_row)
|
||||
for c in range(max_col, min_col - 1, -1):
|
||||
_add_pin(min_row + 1, c, "top", max_col - c)
|
||||
_add_pin(top_number_row, c, "top", max_col - c)
|
||||
|
||||
if not pins:
|
||||
raise StructureError("未检测到任何 Pin 数据")
|
||||
|
||||
@@ -534,6 +534,441 @@ def test_template_color_prefix_auto_fix():
|
||||
print("✓ test_template_color_prefix_auto_fix passed")
|
||||
|
||||
|
||||
# ── F016 + F017: QFN60 端到端测试(15×15 网格,60 引脚)────────
|
||||
|
||||
# QFN60 15×15 PinList 数据
|
||||
QFN60_PINLIST_ENTRIES = [
|
||||
PinListEntry(number=i, name=f"Pin{i}")
|
||||
for i in range(1, 61)
|
||||
]
|
||||
QFN60_PACKAGE_INFO = "QFN60"
|
||||
QFN60_ROWS = 15
|
||||
QFN60_COLS = 15
|
||||
|
||||
|
||||
def test_f017_qfn60_map_to_list():
|
||||
"""F017: 解析 Layout B(用户真实布局:Number 在 Name 上方)QFN60 PinMAP → PinList。
|
||||
|
||||
15×15 网格,60 引脚环形布局。
|
||||
布局 B:Top Number 在 row 1,Top Name 在 row 2。
|
||||
Left 从 row 3 开始(避开 Top Name 行),Bottom/Right 按标准公式。
|
||||
|
||||
验收标准:
|
||||
- 解析出 60 个引脚
|
||||
- 四边各 15 个引脚
|
||||
- 所有引脚序号 1..60 完整
|
||||
- 封装信息 "QFN60" 正确
|
||||
- Top 边全部识别(F013 修复验证)
|
||||
"""
|
||||
# ── 构建 QFN60 Layout B cells ───────────────────────────
|
||||
cells: dict[tuple[int, int], str] = {}
|
||||
cells[(0, 0)] = QFN60_PACKAGE_INFO
|
||||
|
||||
# Top: Number at row 1, Name at row 2 (Layout B)
|
||||
# 逆时针:Pin46 在右 (col 15),Pin60 在左 (col 1)
|
||||
for i, c in enumerate(range(QFN60_COLS, 0, -1)):
|
||||
pin_num = 46 + i
|
||||
cells[(1, c)] = str(pin_num) # Top Number
|
||||
cells[(2, c)] = f"Pin{pin_num}" # Top Name
|
||||
|
||||
# Left: Pin1..Pin15, Number at col 0, Name at col 1
|
||||
# 从 row 3 开始,避免与 Top Name (row 2) 重叠
|
||||
for i in range(QFN60_ROWS):
|
||||
r = 3 + i
|
||||
cells[(r, 0)] = str(i + 1)
|
||||
cells[(r, 1)] = f"Pin{i + 1}"
|
||||
|
||||
# Right: Pin31..Pin45, Number at col 16, Name at col 15
|
||||
# 从下往上 (row 17→3)
|
||||
for i in range(QFN60_ROWS):
|
||||
r = QFN60_ROWS + 2 - i # 17, 16, ..., 3
|
||||
cells[(r, QFN60_COLS + 1)] = str(31 + i)
|
||||
cells[(r, QFN60_COLS)] = f"Pin{31 + i}"
|
||||
|
||||
# Bottom: Pin16..Pin30
|
||||
# Name at row 18 (rows+3?), Number at row 19 (rows+4?)
|
||||
# Actually standard: Name at rows+2=17, Number at rows+3=18
|
||||
# But in user layout, left runs through row 17 and bottom is below that
|
||||
for i in range(QFN60_COLS):
|
||||
c = 1 + i
|
||||
cells[(QFN60_ROWS + 3, c)] = f"Pin{16 + i}" # Name: row 18
|
||||
cells[(QFN60_ROWS + 4, c)] = str(16 + i) # Number: row 19
|
||||
|
||||
# ── 解析 ──────────────────────────────────────────────
|
||||
pm = parse_pinmap(cells)
|
||||
|
||||
# ── 验证 ──────────────────────────────────────────────
|
||||
assert pm.package_info == QFN60_PACKAGE_INFO, (
|
||||
f"封装信息应为 {QFN60_PACKAGE_INFO},实际: {pm.package_info}"
|
||||
)
|
||||
assert len(pm.pins) == 60, (
|
||||
f"应解析出 60 个引脚,实际: {len(pm.pins)}"
|
||||
)
|
||||
|
||||
# 四边各 15 个
|
||||
from collections import Counter
|
||||
edges = Counter(p.edge for p in pm.pins)
|
||||
for edge_name in ("left", "bottom", "right", "top"):
|
||||
assert edges.get(edge_name, 0) == 15, (
|
||||
f"{edge_name} 边应有 15 个引脚,实际: {edges.get(edge_name, 0)}"
|
||||
)
|
||||
|
||||
# 所有序号 1..60 完整
|
||||
numbers = sorted(p.number for p in pm.pins)
|
||||
assert numbers == list(range(1, 61)), (
|
||||
f"引脚序号应完整 1..60\n缺失: {sorted(set(range(1,61)) - set(numbers))}"
|
||||
)
|
||||
|
||||
# ── 验证 Top 边(F013 关键检查)──────────────────────
|
||||
top_pins = [(p.number, p.name) for p in pm.pins if p.edge == "top"]
|
||||
top_pins.sort()
|
||||
expected_top = [(i, f"Pin{i}") for i in range(46, 61)]
|
||||
assert top_pins == expected_top, (
|
||||
f"Top 边引脚不匹配\n预期: {expected_top}\n实际: {top_pins}"
|
||||
)
|
||||
|
||||
# ── 验证所有 PinName ─────────────────────────────────
|
||||
for p in pm.pins:
|
||||
assert p.name == f"Pin{p.number}", (
|
||||
f"Pin{p.number} 的名称应为 Pin{p.number},实际: {p.name}"
|
||||
)
|
||||
|
||||
# ── 验证器 ───────────────────────────────────────────
|
||||
vr = validate_pinmap(pm)
|
||||
assert vr.is_valid, (
|
||||
f"PinMAP 验证失败\n错误: {[e.message for e in vr.errors]}"
|
||||
)
|
||||
|
||||
# ── 生成 PinList ─────────────────────────────────────
|
||||
pl = generate_pinlist(pm, vr)
|
||||
assert len(pl.rows) == 60, (
|
||||
f"PinList 应有 60 行,实际: {len(pl.rows)}"
|
||||
)
|
||||
assert pl.package_info == QFN60_PACKAGE_INFO
|
||||
|
||||
# PinList 按序号排序
|
||||
for i, (name, num) in enumerate(pl.rows):
|
||||
expected_num = i + 1
|
||||
assert num == expected_num, (
|
||||
f"PinList row[{i}]: 预期序号 {expected_num},实际 {num}"
|
||||
)
|
||||
assert name == f"Pin{expected_num}", (
|
||||
f"PinList row[{i}]: 预期名称 Pin{expected_num},实际 {name}"
|
||||
)
|
||||
|
||||
print(f"✓ test_f017_qfn60_map_to_list passed (60 pins, Layout B)")
|
||||
|
||||
|
||||
def test_f017_qfn60_map_to_list_layout_a():
|
||||
"""F017 补充: 解析 Layout A(v1.5.5 生成布局)QFN60 PinMAP → PinList。
|
||||
|
||||
验证自动检测对两种布局均正确工作。
|
||||
Layout A: Top Name 在 row 0,Top Number 在 row 1。
|
||||
"""
|
||||
# 使用生成器生成标准 Layout A 的 cells
|
||||
data = generate_pinmap(
|
||||
entries=QFN60_PINLIST_ENTRIES,
|
||||
rows=QFN60_ROWS,
|
||||
cols=QFN60_COLS,
|
||||
package_info=QFN60_PACKAGE_INFO,
|
||||
template_style=None,
|
||||
output_path=None,
|
||||
)
|
||||
|
||||
from utils import cell_ref_to_rc
|
||||
cells = {cell_ref_to_rc(ref): val for ref, val in data.items()}
|
||||
|
||||
pm = parse_pinmap(cells)
|
||||
|
||||
assert len(pm.pins) == 60, f"应解析出 60 个引脚,实际: {len(pm.pins)}"
|
||||
assert pm.package_info == QFN60_PACKAGE_INFO
|
||||
|
||||
numbers = sorted(p.number for p in pm.pins)
|
||||
assert numbers == list(range(1, 61)), (
|
||||
f"引脚序号不完整: 缺失 {sorted(set(range(1,61)) - set(numbers))}"
|
||||
)
|
||||
|
||||
# Top 边验证
|
||||
top_pins = sorted([(p.number, p.name) for p in pm.pins if p.edge == "top"])
|
||||
expected_top = [(i, f"Pin{i}") for i in range(46, 61)]
|
||||
assert top_pins == expected_top, f"Layout A Top 边: {top_pins} != {expected_top}"
|
||||
|
||||
vr = validate_pinmap(pm)
|
||||
assert vr.is_valid
|
||||
|
||||
pl = generate_pinlist(pm, vr)
|
||||
assert len(pl.rows) == 60
|
||||
print(f"✓ test_f017_qfn60_map_to_list_layout_a passed (60 pins, Layout A)")
|
||||
|
||||
|
||||
def test_f016_qfn60_list_to_map():
|
||||
"""F016: 从 PinList 生成 QFN60 PinMAP,验证 60 引脚完整。
|
||||
|
||||
验收标准:
|
||||
- 生成 121 个单元格(A1 + 60 Name + 60 Number,无边角共享)
|
||||
- A1 = "QFN60"
|
||||
- 所有 60 个引脚都有 Name 和 Number 单元格
|
||||
- 四边布局正确(left/bottom/right/top 各 15 个)
|
||||
"""
|
||||
data = generate_pinmap(
|
||||
entries=QFN60_PINLIST_ENTRIES,
|
||||
rows=QFN60_ROWS,
|
||||
cols=QFN60_COLS,
|
||||
package_info=QFN60_PACKAGE_INFO,
|
||||
template_style=None,
|
||||
output_path=None,
|
||||
)
|
||||
|
||||
# ── 验证单元格总数 ───────────────────────────────────
|
||||
# A1 + 60 Names + 60 Numbers = 121(无边角共享)
|
||||
assert len(data) == 121, (
|
||||
f"应有 121 个单元格 (1 A1 + 60 Name + 60 Number),实际: {len(data)}"
|
||||
)
|
||||
|
||||
# ── 验证 A1 ──────────────────────────────────────────
|
||||
assert data.get("A1") == QFN60_PACKAGE_INFO, (
|
||||
f"A1 应为 {QFN60_PACKAGE_INFO},实际: {data.get('A1')}"
|
||||
)
|
||||
|
||||
# ── 验证所有 60 个引脚都有 Name 和 Number ───────────
|
||||
from utils import cell_ref_to_rc
|
||||
|
||||
name_cells = {}
|
||||
num_cells = {}
|
||||
for ref, val in data.items():
|
||||
if ref == "A1":
|
||||
continue
|
||||
r, c = cell_ref_to_rc(ref)
|
||||
if val.startswith("Pin"):
|
||||
name_cells[(r, c)] = val
|
||||
elif val.isdigit() or "/" in val:
|
||||
num_cells[(r, c)] = val
|
||||
|
||||
# 60 个 Name
|
||||
assert len(name_cells) == 60, (
|
||||
f"应有 60 个 Name 单元格,实际: {len(name_cells)}"
|
||||
)
|
||||
# 60 个 Number(无角点共享时每个序号独占单元格)
|
||||
assert len(num_cells) == 60, (
|
||||
f"应有 60 个 Number 单元格,实际: {len(num_cells)}"
|
||||
)
|
||||
|
||||
# 验证所有 Name 正确
|
||||
for (r, c), val in name_cells.items():
|
||||
assert val.startswith("Pin"), f"Name 单元格 ({r},{c}) 值异常: {val}"
|
||||
|
||||
# 验证所有 Number 正确(1..60)
|
||||
all_numbers = set()
|
||||
for (r, c), val in num_cells.items():
|
||||
for part in val.split("/"):
|
||||
if part.strip().isdigit():
|
||||
all_numbers.add(int(part.strip()))
|
||||
assert all_numbers == set(range(1, 61)), (
|
||||
f"Number 单元格应覆盖 1..60\n缺失: {sorted(set(range(1,61)) - all_numbers)}"
|
||||
)
|
||||
|
||||
# ── 验证四边布局 ───────────────────────────────────
|
||||
# v1.5.5 Layout A:
|
||||
# Top: Name (0, 1..15), Number (1, 15..1)
|
||||
# Left: Number (2..16, 0), Name (2..16, 1)
|
||||
# Bottom: Name (17, 1..15), Number (18, 1..15)
|
||||
# Right: Number (16..2, 16), Name (16..2, 15)
|
||||
|
||||
# Top Names 在 row 0
|
||||
for c in range(1, QFN60_COLS + 1):
|
||||
ref = rc_to_cell_ref(0, c)
|
||||
assert ref in data, f"Top Name {ref} 缺失"
|
||||
assert data[ref].startswith("Pin"), f"Top Name {ref} = {data[ref]}"
|
||||
|
||||
# Top Numbers 在 row 1
|
||||
for c in range(1, QFN60_COLS + 1):
|
||||
ref = rc_to_cell_ref(1, c)
|
||||
assert ref in data, f"Top Number {ref} 缺失"
|
||||
|
||||
# Left Numbers 在 col 0, rows 2..16
|
||||
for r in range(2, QFN60_ROWS + 2):
|
||||
ref = rc_to_cell_ref(r, 0)
|
||||
assert ref in data, f"Left Number {ref} 缺失"
|
||||
|
||||
# Left Names 在 col 1, rows 2..16
|
||||
for r in range(2, QFN60_ROWS + 2):
|
||||
ref = rc_to_cell_ref(r, 1)
|
||||
assert ref in data, f"Left Name {ref} 缺失"
|
||||
assert data[ref].startswith("Pin"), f"Left Name {ref} = {data[ref]}"
|
||||
|
||||
# Bottom Names 在 row 17
|
||||
for c in range(1, QFN60_COLS + 1):
|
||||
ref = rc_to_cell_ref(QFN60_ROWS + 2, c)
|
||||
assert ref in data, f"Bottom Name {ref} 缺失"
|
||||
assert data[ref].startswith("Pin"), f"Bottom Name {ref} = {data[ref]}"
|
||||
|
||||
# Bottom Numbers 在 row 18
|
||||
for c in range(1, QFN60_COLS + 1):
|
||||
ref = rc_to_cell_ref(QFN60_ROWS + 3, c)
|
||||
assert ref in data, f"Bottom Number {ref} 缺失"
|
||||
|
||||
# Right Numbers 在 col 16, rows 16..2
|
||||
for r in range(QFN60_ROWS + 1, 1, -1):
|
||||
ref = rc_to_cell_ref(r, QFN60_COLS + 1)
|
||||
assert ref in data, f"Right Number {ref} 缺失"
|
||||
|
||||
# Right Names 在 col 15, rows 16..2
|
||||
for r in range(QFN60_ROWS + 1, 1, -1):
|
||||
ref = rc_to_cell_ref(r, QFN60_COLS)
|
||||
assert ref in data, f"Right Name {ref} 缺失"
|
||||
assert data[ref].startswith("Pin"), f"Right Name {ref} = {data[ref]}"
|
||||
|
||||
print(f"✓ test_f016_qfn60_list_to_map passed (60 pins, Layout A)")
|
||||
|
||||
|
||||
def test_f017_roundtrip():
|
||||
"""F017 往返: MAP→List→MAP,验证数据不丢失。
|
||||
|
||||
将 QFN60 PinMAP(Layout B)解析为 PinList,
|
||||
再从 PinList 重新生成 PinMAP,解析第二次,
|
||||
验证两次解析结果一致。
|
||||
"""
|
||||
# ── Step 1: 构建 QFN60 Layout B cells ────────────────
|
||||
cells: dict[tuple[int, int], str] = {}
|
||||
cells[(0, 0)] = QFN60_PACKAGE_INFO
|
||||
|
||||
for i, c in enumerate(range(QFN60_COLS, 0, -1)):
|
||||
pin_num = 46 + i
|
||||
cells[(1, c)] = str(pin_num)
|
||||
cells[(2, c)] = f"Pin{pin_num}"
|
||||
|
||||
for i in range(QFN60_ROWS):
|
||||
r = 3 + i
|
||||
cells[(r, 0)] = str(i + 1)
|
||||
cells[(r, 1)] = f"Pin{i + 1}"
|
||||
|
||||
for i in range(QFN60_ROWS):
|
||||
r = QFN60_ROWS + 2 - i
|
||||
cells[(r, QFN60_COLS + 1)] = str(31 + i)
|
||||
cells[(r, QFN60_COLS)] = f"Pin{31 + i}"
|
||||
|
||||
for i in range(QFN60_COLS):
|
||||
c = 1 + i
|
||||
cells[(QFN60_ROWS + 3, c)] = f"Pin{16 + i}"
|
||||
cells[(QFN60_ROWS + 4, c)] = str(16 + i)
|
||||
|
||||
# ── Step 2: MAP → List ───────────────────────────────
|
||||
pm1 = parse_pinmap(cells)
|
||||
vr1 = validate_pinmap(pm1)
|
||||
assert vr1.is_valid
|
||||
pl = generate_pinlist(pm1, vr1)
|
||||
assert len(pl.rows) == 60
|
||||
|
||||
# ── Step 3: List → MAP(使用 generator)──────────────
|
||||
entries2 = [PinListEntry(number=num, name=name) for name, num in pl.rows]
|
||||
data2 = generate_pinmap(
|
||||
entries=entries2,
|
||||
rows=QFN60_ROWS,
|
||||
cols=QFN60_COLS,
|
||||
package_info=pl.package_info,
|
||||
template_style=None,
|
||||
output_path=None,
|
||||
)
|
||||
|
||||
# ── Step 4: MAP → List(第二次解析)─────────────────
|
||||
from utils import cell_ref_to_rc
|
||||
cells2 = {cell_ref_to_rc(ref): val for ref, val in data2.items()}
|
||||
pm2 = parse_pinmap(cells2)
|
||||
vr2 = validate_pinmap(pm2)
|
||||
assert vr2.is_valid
|
||||
pl2 = generate_pinlist(pm2, vr2)
|
||||
|
||||
# ── Step 5: 验证一致性 ───────────────────────────────
|
||||
assert len(pl2.rows) == 60, (
|
||||
f"往返后 PinList 应有 60 行,实际: {len(pl2.rows)}"
|
||||
)
|
||||
|
||||
# 两轮解析的引脚信息应一致
|
||||
pins1 = sorted([(p.number, p.name, p.edge) for p in pm1.pins])
|
||||
pins2 = sorted([(p.number, p.name, p.edge) for p in pm2.pins])
|
||||
assert pins1 == pins2, (
|
||||
f"往返后引脚数据不一致\n原始: {pins1[:10]}...\n往返: {pins2[:10]}..."
|
||||
)
|
||||
|
||||
# PinList 内容应一致(注意:往返后布局可能变 Layout A,
|
||||
# 但 PinList 内容按序号排序应完全一致)
|
||||
for i, ((n1, num1), (n2, num2)) in enumerate(zip(pl.rows, pl2.rows)):
|
||||
assert num1 == num2 == i + 1, (
|
||||
f"往返 PinList row[{i}]: 序号 {num1} vs {num2}"
|
||||
)
|
||||
assert n1 == n2, (
|
||||
f"往返 PinList row[{i}]: 名称 {n1} vs {n2}"
|
||||
)
|
||||
|
||||
assert pl.package_info == pl2.package_info
|
||||
|
||||
print(f"✓ test_f017_roundtrip passed (Layout B→List→Layout A, 60 pins)")
|
||||
|
||||
|
||||
def test_f016_roundtrip():
|
||||
"""F016 往返: List→MAP→List,验证数据不丢失。
|
||||
|
||||
从 60-pin PinList 生成 PinMAP,再解析回 PinList,
|
||||
验证引脚数据完全一致。
|
||||
"""
|
||||
# ── Step 1: List → MAP ───────────────────────────────
|
||||
data = generate_pinmap(
|
||||
entries=QFN60_PINLIST_ENTRIES,
|
||||
rows=QFN60_ROWS,
|
||||
cols=QFN60_COLS,
|
||||
package_info=QFN60_PACKAGE_INFO,
|
||||
template_style=None,
|
||||
output_path=None,
|
||||
)
|
||||
|
||||
# ── Step 2: MAP → List ───────────────────────────────
|
||||
from utils import cell_ref_to_rc
|
||||
cells = {cell_ref_to_rc(ref): val for ref, val in data.items()}
|
||||
pm = parse_pinmap(cells)
|
||||
vr = validate_pinmap(pm)
|
||||
assert vr.is_valid, f"验证失败: {[e.message for e in vr.errors]}"
|
||||
pl = generate_pinlist(pm, vr)
|
||||
|
||||
# ── Step 3: 验证往返一致性 ─────────────────────────
|
||||
assert len(pl.rows) == 60, (
|
||||
f"往返后应有 60 行,实际: {len(pl.rows)}"
|
||||
)
|
||||
assert pl.package_info == QFN60_PACKAGE_INFO
|
||||
|
||||
# 逐行验证
|
||||
for i, (name, num) in enumerate(pl.rows):
|
||||
expected_num = i + 1
|
||||
assert num == expected_num, (
|
||||
f"往返 row[{i}]: 预期序号 {expected_num},实际 {num}"
|
||||
)
|
||||
assert name == f"Pin{expected_num}", (
|
||||
f"往返 row[{i}]: 预期名称 Pin{expected_num},实际 {name}"
|
||||
)
|
||||
|
||||
# ── Step 4: 再从 PinList → MAP 验证一致性 ──────────
|
||||
entries2 = [PinListEntry(number=num, name=name) for name, num in pl.rows]
|
||||
data2 = generate_pinmap(
|
||||
entries=entries2,
|
||||
rows=QFN60_ROWS,
|
||||
cols=QFN60_COLS,
|
||||
package_info=pl.package_info,
|
||||
template_style=None,
|
||||
output_path=None,
|
||||
)
|
||||
|
||||
# 两次生成的 PinMAP 应一致
|
||||
assert len(data) == len(data2), (
|
||||
f"两次生成 PinMAP 单元格数不一致: {len(data)} vs {len(data2)}"
|
||||
)
|
||||
for ref in data:
|
||||
assert ref in data2, f"第二次生成缺失单元格: {ref}"
|
||||
assert data[ref] == data2[ref], (
|
||||
f"单元格 {ref} 值不一致: {data[ref]} vs {data2[ref]}"
|
||||
)
|
||||
|
||||
print(f"✓ test_f016_roundtrip passed (List→MAP→List→MAP, 60 pins)")
|
||||
|
||||
|
||||
def test_template_no_styles_xml():
|
||||
"""边界测试:缺失 styles.xml 时优雅降级。"""
|
||||
from template_reader import read_template_styles
|
||||
@@ -577,4 +1012,10 @@ if __name__ == "__main__":
|
||||
test_template_empty_fonts_fallback()
|
||||
test_template_color_prefix_auto_fix()
|
||||
test_template_no_styles_xml()
|
||||
# v1.6 F016 + F017: QFN60 端到端测试
|
||||
test_f017_qfn60_map_to_list()
|
||||
test_f017_qfn60_map_to_list_layout_a()
|
||||
test_f016_qfn60_list_to_map()
|
||||
test_f017_roundtrip()
|
||||
test_f016_roundtrip()
|
||||
print("\n✅ All tests passed!")
|
||||
|
||||
Reference in New Issue
Block a user