v1.6.0 修复 PinMAP→PinList 上方引脚丢失 + 双向模板样式 + QFN60 端到端验证

F013: Code/src/pinmap_parser.py 增加 Top 边自动布局检测
F014/F015: 双向模板样式确认
F016/F017: 新增 5 个 QFN60 端到端测试
This commit is contained in:
2026-06-12 20:45:51 +08:00
parent 88a231424c
commit 3c5fcff1d5
7 changed files with 1158 additions and 20 deletions

View File

@@ -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) 元组。
布局 Av1.5.5 默认Name 在 row 0Number 在 row 1
布局 B用户真实Number 在 row 1Name 在 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)
# 布局 Av1.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 1Name 在 row 2
# → row 0 无数据(仅 A1row 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)
# 默认回退:假设布局 Av1.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 的相对位置。
# 布局 Av1.5.5 默认Name 在 row 0Number 在 row 1
# 布局 B用户真实Number 在 row 1Name 在 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 数据")

View File

@@ -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 引脚环形布局。
布局 BTop Number 在 row 1Top 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 Av1.5.5 生成布局QFN60 PinMAP → PinList。
验证自动检测对两种布局均正确工作。
Layout A: Top Name 在 row 0Top 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 PinMAPLayout 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!")