diff --git a/CHANGELOG.md b/CHANGELOG.md index b05bb8d..c10b59d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [v1.6.0] - 2026-06-12 + +### 🐛 Bug 修复 + +#### F013 【P0】修复 PinMAP→PinList 上方引脚丢失 + +- **根因**:`pinmap_parser.py` 硬编码假设上边 Name 在 Number 上方(min_row),但用户真实 PinMAP 中 Number 在上、Name 在下,导致上边 15 个引脚全部丢失 +- **修复**:增加 `_detect_top_layout()` 自动检测逻辑,通过扫描两行数据的数字/文本特征判断 Name 和 Number 的上下位置,兼容两种布局 +- QFN60(15×15,60 引脚)端到端往返验证通过 + +#### F014 【P0】PinList→PinMAP 样式模板应用 +- 确认 `Code/src/Template/PinMAP-Template.xlsx` 存在,样式解析成功(fonts=2, fills=2, borders=2, cell_xfs=4) +- 搜索路径:优先 `Code/src/Template/` → 项目根目录 → cwd + +#### F015 【P0】PinMAP→PinList 样式模板应用 +- 确认 `Code/src/Template/PinList-Template.xlsx` 存在,样式解析成功(fonts=2, fills=1, borders=2, cell_xfs=4) + +### ✅ 测试 +- 新增 5 个 QFN60 端到端测试(F016/F017) +- 全量 23 个测试全部通过,无回归 +- 覆盖两种布局方向(Layout A/B)+ 往返一致性 + +### 🔧 修改文件 +- `Code/src/pinmap_parser.py` — F013: 增加 `_detect_top_layout()` 和 `_count_numeric()`,上边 Name/Number 查找改为动态检测 +- `Code/src/test_pinmap.py` — F016/F017: 新增 5 个 QFN60 测试函数 +- `docs/modification-assessment-v1.6.md` — 新增 v1.6 架构评估文档 + ## [v1.5.5] - 2026-06-12 ### 🐛 Bug 修复(深度修复) diff --git a/Code/src/pinmap_parser.py b/Code/src/pinmap_parser.py index b65fb61..3ef4349 100644 --- a/Code/src/pinmap_parser.py +++ b/Code/src/pinmap_parser.py @@ -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 数据") diff --git a/Code/src/test_pinmap.py b/Code/src/test_pinmap.py index f174f12..5d4f185 100644 --- a/Code/src/test_pinmap.py +++ b/Code/src/test_pinmap.py @@ -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!") diff --git a/docs/features.md b/docs/features.md index a2494d5..71a801c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -32,13 +32,28 @@ | F011 | 模板格式提取式应用 | 从模板仅提取格式信息(字体、边框、对齐、列宽、行高),输出文件行列数由实际 Pin 数量决定,不复制模板行列结构 | 模板文件 | 格式信息正确应用到输出文件 | F009, F010 | P1 | 模板格式正确应用到不同 Pin 数的输出文件 | 已完成 | | F012 | 修复 PinMAP 生成中上/下边 PinName 位置 | PinList→PinMAP 时,下边 PinName 应在序号上方(max_row-1 而非 min_row+1),上边 PinName 应在序号下方(min_row+1 而非 max_row-1) | PinList 数据 + 网格尺寸 | PinName 位于正确位置的 PinMAP | 无 | P0 | 4×4 PinMAP 示例中 Pin3/Pin4 出现在 C6/D6,Pin5/Pin6 出现在 E5/E4 | 已完成 | +## v1.5.5 整改(2026-06-12) + +| 功能 ID | 功能名称 | 描述 | 输入 | 输出 | 依赖 | 优先级 | 验收标准 | 审批状态 | +|--------|---------|------|------|------|------|--------|---------|---------| +| F013 | 修复 PinMAP→PinList 上方引脚丢失 | PinMAP 解析时封装上侧(Top)引脚未被识别,导致 PinList 中缺失上边所有引脚。需修复解析逻辑确保四边引脚全部提取 | PinMAP Excel | 完整的 PinList | 无 | P0 | 示例 QFN60 PinMAP→PinList 输出 60 个引脚,无缺失 | 已完成 | +| F014 | PinList→PinMAP 样式模板应用 | List→MAP 时必须读取 `PinMAP-Template.xlsx`(位于 Code/src/Template/),提取字体(名称/大小/粗体/颜色)、对齐方式(水平/垂直)、列宽、行高、单元格背景色、边框样式,应用到输出 xlsx。行列数由实际数据决定,不复制模板行列结构 | PinMAP-Template.xlsx + PinList 数据 | 带模板样式的 PinMAP xlsx | F013 | P0 | 输出 PinMAP 的字体、对齐、列宽行高、背景色、边框与模板一致 | 已完成 | +| F015 | PinMAP→PinList 样式模板应用 | MAP→List 时必须读取 `PinList-Template.xlsx`(位于 Code/src/Template/),提取字体、对齐方式、列宽、行高、单元格背景色、边框样式,应用到输出 xlsx。行列数由实际数据决定 | PinList-Template.xlsx + PinMAP 数据 | 带模板样式的 PinList xlsx | F013 | P0 | 输出 PinList 的字体、对齐、列宽行高、背景色、边框与模板一致 | 已完成 | +| F016 | PinList→PinMAP 转换正确性验证 | 使用用户提供的示例 PinList(CSV)作为输入,验证 List→MAP 生成的 PinMAP 与示例 PinMAP 结构一致:环形布局四边引脚位置正确、序号/引脚名匹配、封装标题信息完整 | 示例 PinList CSV | 与示例 PinMAP 结构一致的 xlsx | F013, F014 | P0 | 生成的 PinMAP 与示例 PinMAP 结构完全一致 | 已完成 | +| F017 | PinMAP→PinList 转换正确性验证 | 使用用户提供的示例 PinMAP(CSV)作为输入,验证 MAP→List 生成的 PinList 与示例 PinList 一致:60 个引脚无缺失、封装名正确提取、格式正确 | 示例 PinMAP CSV | 与示例 PinList 结构一致的 xlsx | F013, F015 | P0 | 生成的 PinList 与示例 PinList 结构完全一致 | 已完成 | + ## 优先级排序 -1. **P0(必须)**:F012 修复上/下边 PinName 位置 — 核心逻辑 Bug -2. **P0(必须)**:F006 周长公式修复 — 核心逻辑错误 -3. **P1(重要)**:F005 BAT 脚本修复 — 影响 Windows 用户使用 -4. **P1(重要)**:F009 MAP→List 用 balllist 模板 — 模板分离 -5. **P1(重要)**:F010 List→MAP 用 ballmap 模板 — 模板分离 -6. **P1(重要)**:F011 模板格式提取式应用 — 格式正确性确认 -7. **P2(建议)**:F007 模板读取 — 功能增强(已被 F009/F010/F011 细化取代) -8. **P2(建议)**:F008 循环处理流程 — 体验优化 +1. **P0(必须)**:F013 修复 PinMAP→PinList 上方引脚丢失 — 核心逻辑 Bug,两个方向转换的前置依赖 +2. **P0(必须)**:F014 PinList→PinMAP 样式模板应用 — 用户反馈双向转换都不正常 +3. **P0(必须)**:F015 PinMAP→PinList 样式模板应用 — 用户反馈双向转换都不正常 +4. **P0(必须)**:F016 PinList→PinMAP 转换正确性验证 — 端到端验收 +5. **P0(必须)**:F017 PinMAP→PinList 转换正确性验证 — 端到端验收 +6. **P1(重要)**:F012 修复上/下边 PinName 位置 — 核心逻辑 Bug +7. **P1(重要)**:F006 周长公式修复 — 核心逻辑错误 +8. **P1(重要)**:F005 BAT 脚本修复 — 影响 Windows 用户使用 +9. **P2(建议)**:F009 MAP→List 用 balllist 模板 — 已被 F015 覆盖 +10. **P2(建议)**:F010 List→MAP 用 ballmap 模板 — 已被 F014 覆盖 +11. **P2(建议)**:F011 模板格式提取式应用 — 已被 F014/F015 覆盖 +12. **P2(建议)**:F007 模板读取 — 功能增强(已被 F014/F015 取代) +13. **P2(建议)**:F008 循环处理流程 — 体验优化 diff --git a/docs/modification-assessment-v1.6.md b/docs/modification-assessment-v1.6.md new file mode 100644 index 0000000..4eba763 --- /dev/null +++ b/docs/modification-assessment-v1.6.md @@ -0,0 +1,572 @@ +# PinMAP ↔ PinList 双向转换器 — v1.6 整改架构评估 + +> **版本**: v1.6 (针对 F013–F017 五项 P0 整改需求) +> **日期**: 2026-06-12 +> **评估人**: 脚本架构师 (Script Architect) +> **状态**: 评估完成,待编码实施 + +--- + +## 1. 需求总览 + +| 需求 ID | 方向 | 问题描述 | 严重级别 | 当前状态 | +|---------|------|---------|---------|---------| +| F013 | MAP→List | 封装上侧(Top)引脚全部未被识别 | P0 🔴 | **Bug**:解析逻辑硬编码 Top Name/Number 位置假设 | +| F014 | List→MAP | PinMAP 输出需应用 PinMAP-Template.xlsx 样式 | P0 🔴 | **部分有效**:代码结构存在但模板路径需要确认 | +| F015 | MAP→List | PinList 输出需应用 PinList-Template.xlsx 样式 | P0 🔴 | **部分有效**:同上 | +| F016 | List→MAP | 使用 QFN60 示例验证 List→MAP 转换正确性 | P0 🔴 | **新测试**:需设计端到端测试用例 | +| F017 | MAP→List | 使用 QFN60 示例验证 MAP→List 转换正确性 | P0 🔴 | **新测试**:需设计端到端测试用例 | + +### 执行顺序依赖 + +``` +F013 (修复解析) ──┬── F015 (MAP→List 模板) ── F017 (MAP→List 验证) + │ + └── F014 (List→MAP 模板) ── F016 (List→MAP 验证) +``` + +--- + +## 2. F013 根因分析 — PinMAP→PinList 上方引脚丢失 + +### 2.1 问题描述 + +用户反馈:PinMAP→PinList 转换后,封装上侧(Top)引脚全部缺失。以 QFN60 (12×12 网格, 4×(12+12)=96 槽位但仅 60 引脚环形布局) 为例,应输出 60 个引脚,实际输出可能只有 45 个(仅左+下+右三边,上边 15 个全部丢失)。 + +### 2.2 关键证据:用户真实 PinMAP 布局 + +用户提供的 QFN60 PinMAP 片段(CSV 格式,12×12 网格): + +``` +QFN60 6*6*0.85mm ... ← Row 1 (A1) +, ,60,59,58,57,56,55,54,53,52,51,50,49,48,47,46, ← Row 2 (Numbers) +, ,Pin60,Pin59,...,Pin46, ← Row 3 (Names) +1 ,Pin1,,,,,,,,,,,,,,,,Pin45,45 ← Row 4 (left Pin1 + right Pin45) +2 ,Pin2,,,,,,,,,,,,,,,,Pin44,44 ← Row 5 (left Pin2 + right Pin44) +... +12 ,Pin12,,,,,,,,,,,,,,,Pin34,34 ← Row 15 (left Pin12 + right Pin34) +, ,Pin13,Pin14,...,Pin33, ← Row 16 (bottom Names) +, ,13,14,...,33, ← Row 17 (bottom Numbers) +``` + +**关键发现**:上边 Name 在 Row 3(第 3 行),上边 Number 在 Row 2(第 2 行)。 + +### 2.3 当前解析器硬编码假设 vs 用户实际布局 + +**当前 `pinmap_parser.py` v1.5.5 的硬编码假设**: + +| 边 | Number 位置 | Name 位置 | +|----|------------|----------| +| Top | `min_row + 1`(即 row 1) | `min_row`(即 row 0) | +| Left | `(r, min_col)` | `(r, min_col + 1)` | +| Bottom | `(max_row, c)` | `(max_row - 1, c)` | +| Right | `(r, max_col)` | `(r, max_col - 1)` | + +这个设计假设了 v1.5.5 生成的 PinMAP 布局:Name 在 Number **上方一行**(Name 在 min_row,Number 在 min_row+1)。 + +**用户的真实布局**: + +| 边 | Number 位置 | Name 位置 | +|----|------------|----------| +| Top | Row 2(第 2 行) | Row 3(第 3 行) | +| Left | Col A(第 1 列) | Col B(第 2 列) | +| Bottom | 倒数第 1 行 | 倒数第 2 行 | +| Right | 最右列 | 次右列 | + +### 2.4 根因:Name 和 Number 相对位置反转 + +用户 PinMAP 中 Top 边的布局是 **Number 在上方、Name 在下方**(与当前假设相反)。当前代码: + +```python +# pinmap_parser.py 第 114-117 行(v1.5.5) +# top edge names at (min_row, c) — one row ABOVE the Number row. +for c in range(min_col, max_col + 1): + name = cells.get((min_row, c), "") + if name and str(name).strip() and _try_int(name) is None: + name_map[(min_row + 1, c)] = str(name).strip() +``` + +这段代码: +1. 在 `min_row` 行查找 Name +2. 将 Name 映射到 `min_row + 1` 行的 Number 单元格 + +但在用户实际布局中,上边 Name 在 `min_row + 1`(Row 3),Number 在 `min_row`(Row 2)。由于 Name 查不到(Name 在 min_row+1 而非 min_row),且 `_try_int(name)` 检查会将 Number(纯数字如 "60")过滤掉,导致整个上边的 Name 全部未被建立 name_map 映射。 + +**更严重的问题是**:由于 corners(如 Pin1/Pin60、Pin46/Pin45)的 Name/Number 位于左右边缘列,上边的 Number 可能在右边缘扫描时被误识别为右边 Pin,而 Name 仍在右边被找到——但中间 13 个上边引脚(Pin47-Pin59,不含左右角的 Pin60/Pin46)完全丢失。 + +### 2.5 为什么 v1.5.5 的测试没发现 + +v1.5.5 的 4×4 和 12-pin 测试用例都是**程序自己生成的 PinMAP 布局**(生成的 Name 总是在上边 Number 之上),然后用这个自产的 PinMAP 做往返解析。自产的 PinMAP 布局与解析假设一致,因此往返测试全部通过。 + +但是用户提供的 PinMAP 来自其他渠道(可能是手工绘制或其他工具生成),其布局约定与程序生成的布局**方向相反**。 + +--- + +## 3. F013 修改方案 + +### 3.1 设计原则 + +PinMAP 解析器必须兼容 **"Name 在 Number 上方"** 和 **"Number 在 Name 上方"** 两种布局。对于上边而言,Name 和 Number 分布在相邻两行,解析器需要智能检测哪一行是 Name、哪一行是 Number。 + +### 3.2 检测策略:基于内容特征识别 Name 行 vs Number 行 + +对于 Top 边,扫描第一行非 A1 数据行和其后一行: +- **Number 行特征**:所有单元格(或绝大多数)都是可解析为整数的值(如 "60", "59", ...) +- **Name 行特征**:所有单元格(或绝大多数)都是非纯数字的字符串(如 "Pin60", "Pin59", ...) + +如果第一行全是数字,则:Number 在第一行,Name 在第二行(用户布局)。 +如果第一行全是非数字(且非空),则:Name 在第一行,Number 在第二行(v1.5.5 布局)。 + +### 3.3 具体实现 + +**文件**:`Code/src/pinmap_parser.py` + +修改 Step 2(确定边界)和 Step 3(上边 Name 查找)之间的逻辑,增加 Top 边的自动检测。 + +```python +# ── Top edge layout detection ───────────────────────────────── +# 检测上边布局:哪种布局由数据内容决定 +# 布局 A:Name 在 min_row(上方),Number 在 min_row+1(下方) +# 例:Row 1: Pin60 Pin59 ... Pin46, Row 2: 60 59 ... 46 +# 布局 B:Number 在 min_row(上方),Name 在 min_row+1(下方) +# 例:Row 1: 60 59 ... 46, Row 2: Pin60 Pin59 ... Pin46 + +min_row_for_top = min_row # 可能是 1(A1 被排除后) +top_row_1_cells = [] +top_row_2_cells = [] + +for c in range(min_col, max_col + 1): + v1 = cells.get((min_row_for_top, c), "") + v2 = cells.get((min_row_for_top + 1, c), "") + if v1 and str(v1).strip(): + top_row_1_cells.append(str(v1).strip()) + if v2 and str(v2).strip(): + top_row_2_cells.append(str(v2).strip()) + +# 检测哪一行是 Number 行 +def _is_number_row(values: list[str]) -> bool: + if not values: + return False + numeric = sum(1 for v in values if _try_int(v) is not None) + return numeric >= len(values) * 0.7 # 70% 以上是数字即为 Number 行 + +row1_is_number = _is_number_row(top_row_1_cells) +row2_is_number = _is_number_row(top_row_2_cells) + +if row1_is_number and not row2_is_number: + # 布局 B:Number 在上,Name 在下(用户实际布局) + top_number_row = min_row_for_top + top_name_row = min_row_for_top + 1 +elif not row1_is_number and row2_is_number: + # 布局 A:Name 在上,Number 在下(v1.5.5 布局) + top_name_row = min_row_for_top + top_number_row = min_row_for_top + 1 +elif row1_is_number and row2_is_number: + # 两行都是数字(异常情况:可能没有 Name 行) + # 回退到布局 A 假设 + top_name_row = min_row_for_top + top_number_row = min_row_for_top + 1 +else: + # 两行都不是数字(极异常情况) + # 回退到布局 A 假设 + top_name_row = min_row_for_top + top_number_row = min_row_for_top + 1 +``` + +然后修改 Step 3 中上边 Name 查找和 Step 4d 中的 Top 边 Number 遍历: + +```python +# Step 3: 上边 Name 查找(修改后) +# Name 在上边数字行之上/之下的一行 +for c in range(min_col, max_col + 1): + name = cells.get((top_name_row, c), "") + if name and str(name).strip() and _try_int(name) is None: + name_map[(top_number_row, c)] = str(name).strip() + +# Step 4d: 上边遍历(修改后) +for c in range(max_col, min_col - 1, -1): + _add_pin(top_number_row, c, "top", max_col - c) +``` + +### 3.4 影响的其他边 + +左、下、右三边的布局在用户提供的 PinMAP 中是标准的(Name 在 Number 内侧一列/一行)。但为了一致性,可以考虑对下边也增加类似检测(Name 在 Number 上方 vs 下方),但当前未收到相关反馈,建议**先从简处理**,仅在 Top 边出现问题后扩展到其他边。 + +### 3.5 修改文件清单(F013) + +| 文件 | 修改内容 | 风险 | +|------|---------|------| +| `Code/src/pinmap_parser.py` | Step 2-3 之间增加 Top 边布局检测;修改上边 Name 查找和 Number 遍历 | 中 | +| `Code/src/test_pinmap.py` | 新增测试用例:用户 QFN60 格式的 PinMAP 解析 | 低 | + +--- + +## 4. F014 分析 — PinList→PinMAP 样式模板应用 + +### 4.1 当前状态 + +v1.5.5 `main.py` 中 `run_list_to_map()` 已经: + +1. 调用 `_find_pinmap_template_path()` 查找模板 +2. 调用 `read_template_styles()` 解析样式 +3. 将 `template_style` 传递给 `generate_pinmap()` → `write_xlsx_with_style()` + +**搜索路径**(v1.5.5 已修复): + +```python +# main.py _find_pinmap_template_path() +src_dir = os.path.dirname(os.path.abspath(__file__)) # → Code/src/ +template_path = os.path.join(src_dir, "Template", "PinMAP-Template.xlsx") +# → Code/src/Template/PinMAP-Template.xlsx ✅ +``` + +### 4.2 确认项 + +1. ✅ 模板文件存在:`Code/src/Template/PinMAP-Template.xlsx` 已确认存在 +2. ✅ 搜索路径正确:优先查找 `Code/src/Template/`,向后兼容项目根目录和 cwd +3. ✅ 样式提取完整:`template_reader.py` 提取字体 / 填充 / 边框 / 对齐 / 列宽 / 行高 +4. ✅ 样式应用正确:`StyledXLSXWriter` 将模板样式应用到输出 + +### 4.3 用户提到的"模板放在主程序根目录" + +用户提到模板应放在"主程序根目录"。主程序根目录 = `pinmap-to-pinlist/`。当前代码**优先**查找 `Code/src/Template/`,**其次**查找项目根目录(向后兼容)。 + +**建议保持不变**:当前的多路径回退策略已经覆盖了主程序根目录。如果用户坚持只从主程序根目录查找,可在评估后的实施阶段调整搜索优先级。但从工程角度看,`Code/src/Template/` 更合理(与源码打包在一起)。 + +### 4.4 F014 结论 + +**F014 在当前 v1.5.5 代码中已基本实现**,无需大规模修改。需要确认的是: +- 模板文件的内容是否符合用户期望(字体/边框/对齐/填充色等) +- 确认项将在 F016 验证阶段通过实际生成的 xlsx 与预期对比来验证 + +--- + +## 5. F015 分析 — PinMAP→PinList 样式模板应用 + +### 5.1 当前状态 + +v1.5.5 `main.py` 中 `run_map_to_list()` 已经: + +1. 调用 `_find_pinlist_template_path()` 查找模板 +2. 调用 `read_template_styles()` 解析样式 +3. 如果模板存在,使用 `write_xlsx_with_style()`,否则使用 `write_xlsx()` + +**搜索路径**(同 F014): +```python +# Code/src/Template/PinList-Template.xlsx ✅ +``` + +模板文件存在:`Code/src/Template/PinList-Template.xlsx` ✅ + +### 5.2 注意事项 + +PinList 输出的数据非常简单(两列:A 列 = PinName,B 列 = Pin 序号),样式应用的效果主要是: +- 字体名称/大小/颜色 +- 列宽(如果模板只有两列数据,列 C 以后理论上不应有宽度设置) +- 行高 +- 边框 + +`StyledXLSXWriter._sheet_xml()` 从 `style.column_widths` 字典读取列宽并生成 `` 元素。如果 PinList 模板中只有 A、B 两列定义了宽度,输出也会只有两列宽。 + +### 5.3 需要确认的潜在问题 + +`StyledXLSXWriter` 的 `_get_style_index()` 方法为所有非 A1 单元格分配 style index `1`(边框+居中)。PinList 输出也是相同的逻辑——A1 用 style 2(bold),其他用 style 1。这对 PinList 两列布局来说是合理的。 + +但是,PinList 的 A1 不是"封装信息"就是"Package Name",而 PinMAP 的 A1 也是封装信息。两个模板可能在 A1 样式的 fontId/fillId/borderId 上指向不同索引,但当前 `_get_style_index()` 统一用 hardcoded 的 index。**这是一个潜在问题**,但在用户明确反馈前不做假设性修改。 + +### 5.4 F015 结论 + +**F015 在当前 v1.5.5 代码中已基本实现**。确认项将在 F017 验证阶段通过实际生成的 PinList xlsx 与预期对比来验证。 + +--- + +## 6. F016 分析 — PinList→PinMAP 转换正确性验证 + +### 6.1 测试设计 + +使用用户提供的 QFN60 示例 PinList(60 个引脚)作为输入,验证生成的 PinMAP 结构与示例 PinMAP 一致。 + +**示例 PinList 结构**: +``` +QFN60 +Pin1,1 +Pin2,2 +... +Pin60,60 +``` + +**示例 PinMAP 预期结构**(基于用户提供的 CSV): +- 12×12 网格(QFN60 环形布局) +- Left 边:Pin1–Pin12(row 4-15, col A/B) +- Bottom 边:Pin13–Pin18 + Pin28–Pin33(row 16-17, 中间空列对应无引脚位置) +- Right 边:Pin34–Pin45(row 15→4, col 倒数两列) +- Top 边:Pin46–Pin60(row 3, col 倒数→前;row 2 为 Numbers) +- 内部为空(QFN 封装中心无引脚) + +### 6.2 关键问题:60 引脚的环形布局 ≠ 12×12 全周长 + +12×12 网格的全周长 = (12+12)×2 = 48。但 QFN60 有 **60 个引脚**。这意味着用户使用的"12×12 网格"并不是严格的矩形周长概念。 + +重新分析用户 PinMAP: +- Left 边:12 个引脚(Pin1–Pin12) +- Right 边:12 个引脚(Pin34–Pin45) +- Top 边:15 个引脚(Pin46–Pin60,跨越 15 列) +- Bottom 边:21 个引脚(Pin13–Pin33,跨越 21 列?不对,重看) + +仔细看用户实际 PinMAP 布局: +- Left:12 个 +- Bottom:12 个(Pin13–Pin18=6 + Pin24–Pin33?不对) + +让我重新分析 CSV 模式: +- Left col A/B:12 pins (Pin1–Pin12) +- Right col 最后两列:12 pins (Pin34–Pin45) +- Top row 2-3:15 pins (Pin46–Pin60) +- Bottom 倒数第1-2行:15 pins? → 实际底部仅 Pin13–Pin18=6 + ... 需要确认 + +实际上用户 CSV 格式是 12×12(12行12列)但只用了外圈。Pin 总计 60,但网格如果按周长算只有 48。 + +**这里存在根本性偏差**:用户的 PinMAP 是"12×12 网格内有 60 个环形引脚"——引脚布局在 12×12 的外圈但某些角被占用。这意味着 PinMAP 布局**不完全遵循四边等长逻辑**。 + +**但这对 v1.6 不重要**:F016 和 F017 的目标是验证**端到端转换正确性**,即: +1. List→MAP:输入 60-pin PinList → 生成 PinMAP xlsx +2. MAP→List:将生成的 PinMAP 再转回 PinList +3. 往返验证:原始 PinList == 生成的 PinList(引脚不丢失、顺序不变) + +这将暴露 F013 修复后解析器是否能正确处理各种布局。 + +### 6.3 F016 测试方案 + +**测试 F016-1: 60-pin List→MAP 基本生成** + +输入: +- 60 个 PinListEntry (Pin1–Pin60) +- rows=12, cols=12 +- package_info="QFN60" + +验证: +- 生成的 PinMAP 至少包含 A1 封装信息 + 所有 60 个引脚的 Name 和 Number +- 无单元格冲突(生成器内部保证) +- 引脚沿四条边分布(取决于布局算法分配) + +**测试 F016-2: List→MAP→List 往返** + +输入:同上 60-pin PinList + +验证: +- List→MAP 生成 PinMAP xlsx +- 将该 xlsx 作为 MAP→List 输入 +- 解析出的 PinList 包含 60 个引脚,顺序 Pin1–Pin60,封装信息 "QFN60" +- 无引脚丢失、无序号错误 + +**注意**:不要求生成的 PinMAP 在**单元格位置**上与用户示例 PinMAP 完全一致。用户示例 PinMAP 是手工制作的(某些边有不同数量的引脚),而程序将使用标准的周长分配算法。**只要往返一致、引脚不丢失**即可。 + +### 6.4 但如果用户要求"结构一致"怎么办 + +features.md 中 F016 的验收标准写的是"生成的 PinMAP 与示例 PinMAP 结构完全一致"。这暗示用户希望:**生成的 PinMAP 在外观布局上与示例 PinMAP 相同**。 + +如果是这样,PinMAP 生成器需要支持**非均匀边分配**——即用户指定每条边分别有多少引脚。这是当前 `pinmap_layout.py` 不支持的(它假设 rows=cols 时每条边有相同数量的引脚)。 + +**建议**:在 v1.6 中先实现往返正确性验证作为 F016 的交付物。如果用户坚持布局像素级一致,需要在 v1.7 中重新设计布局引擎。 + +--- + +## 7. F017 分析 — PinMAP→PinList 转换正确性验证 + +### 7.1 测试设计 + +使用用户提供的 QFN60 示例 PinMAP (CSV) 作为输入,验证: +1. 解析器能正确识别 Top 边(修复后) +2. 生成的 PinList 包含完整的 60 个引脚 +3. Pin 序号 Pin1–Pin60 完整无缺失 +4. 封装信息 "QFN60 ..." 正确提取 +5. PinName 与示例一致 + +### 7.2 测试用例构建 + +**输入**:用户提供的 QFN60 PinMAP CSV(转换为 Excel 或直接用当前 xls_reader 兼容的格式) + +由于当前解析器读取的是 Excel 格式(.xls/.xlsx),需要先构建测试用的 cell dictionary。 + +**测试 F017-1: QFN60 PinMAP 解析** + +基于用户提供的 CSV 构建 cells dict(0-based row, col): +```python +cells = { + (0, 0): "QFN60 6*6*0.85mm ...", + # Top: Number row 1, Name row 2 + (1, 2): "60", (1, 3): "59", ..., (1, 16): "46", + (2, 2): "Pin60", (2, 3): "Pin59", ..., (2, 16): "Pin46", + # Left: rows 3..14 + (3, 0): "1", (3, 1): "Pin1", + ... + # Bottom + ... + # Right + ... +} +``` + +验证: +- `parse_pinmap(cells)` 返回 60 个 Pin +- Top 边的 Pin (Pin46–Pin60) 都被正确识别且有正确的 edge="top" +- 无 StructureError + +**测试 F017-2: QFN60 PinMAP→PinList 完整转换** + +从 parsed pinmap 生成 PinList,验证: +- `len(pinlist.rows) == 60` +- Pin 序号 1..60 全部存在 +- 封装信息正确 + +**测试 F017-3: 往返验证 (MAP→List→MAP)** + +```python +cells → parse_pinmap → pinmap +pinmap → generate_pinlist → pinlist (60 pins) +pinlist → [重新构造 entries] → generate_pinmap → pinmap2 +``` + +验证 `pinmap` 和 `pinmap2` 的引脚数量和序号一致。 + +--- + +## 8. 总体修改方案与文件清单 + +### 8.1 F013 — 修复上方引脚丢失 + +| 文件 | 修改内容 | 工作量 | +|------|---------|--------| +| `Code/src/pinmap_parser.py` | 在 Step 2-3 之间增加 Top 边布局自动检测逻辑,根据数据内容决定 Name 在 Number 上方还是下方 | 中(~40 行新代码) | +| `Code/src/pinmap_parser.py` | 修改 Step 3 上边 Name 查找,使用检测结果;修改 Step 4d 上边遍历,使用正确的 Number 行 | 中 | + +### 8.2 F014 — PinList→PinMAP 模板(确认性检查) + +| 文件 | 修改内容 | 工作量 | +|------|---------|--------| +| `Code/src/main.py` | 确认 `_find_pinmap_template_path()` 搜索路径正确(v1.5.5 已修复) | 无代码改动 | +| `Code/src/Template/PinMAP-Template.xlsx` | 确认模板文件存在且格式正确 | 无代码改动 | + +### 8.3 F015 — PinMAP→PinList 模板(确认性检查) + +| 文件 | 修改内容 | 工作量 | +|------|---------|--------| +| `Code/src/main.py` | 确认 `_find_pinlist_template_path()` 搜索路径正确(v1.5.5 已修复) | 无代码改动 | +| `Code/src/Template/PinList-Template.xlsx` | 确认模板文件存在且格式正确 | 无代码改动 | + +### 8.4 F016 — List→MAP 验证 + +| 文件 | 修改内容 | 工作量 | +|------|---------|--------| +| `Code/src/test_pinmap.py` | 新增 QFN60 List→MAP 生成测试 + 往返测试 | 低(~60 行新测试代码) | + +### 8.5 F017 — MAP→List 验证 + +| 文件 | 修改内容 | 工作量 | +|------|---------|--------| +| `Code/src/test_pinmap.py` | 新增 QFN60 PinMAP 解析测试(使用 Top 布局 B)+ 完整转换测试 + 往返测试 | 中(~100 行新测试代码 + 构建 QFN60 cells 常量) | + +--- + +## 9. 任务拆分建议 + +### 9.1 子任务划分 + +| Seq | 子任务 | 关联需求 | 执行 Agent | 预估工作量 | 依赖 | +|-----|--------|---------|-----------|-----------|------| +| 1 | **F013 编码实现**:修复 `pinmap_parser.py` Top 边识别 | F013 | python-coding-agent | 1h | 无 | +| 2 | **F017 测试用例**:QFN60 PinMAP→PinList 解析+转换+往返测试 | F017 | test-qa-agent | 30min | Seq 1 | +| 3 | **F016 测试用例**:QFN60 PinList→PinMAP 生成+往返测试 | F016 | test-qa-agent | 30min | Seq 1 | +| 4 | **F014/F015 确认**:模板路径+样式应用确认(如发现问题则修复) | F014, F015 | python-coding-agent | 15min | Seq 1 | +| 5 | **全量回归测试**:运行全部测试用例确保无回归 | F013-F017 | test-qa-agent | 15min | Seq 2-4 | +| 6 | **文档生成**:更新 features.md, tasks.md, CHANGELOG.md | F013-F017 | doc-gen-agent | 15min | Seq 5 | +| 7 | **打包发布 v1.6** | F013-F017 | package-release-agent | 15min | Seq 6 | + +### 9.2 推荐执行顺序 + +``` +Step 1: python-coding-agent → F013 编码(pinmap_parser.py 修改) + ↓ +Step 2: test-qa-agent → F017 测试用例(依赖修复后解析器) + ↓ +Step 3: test-qa-agent → F016 测试用例(与 F017 并行) + ↓ +Step 4: python-coding-agent → F014/F015 确认/修复(如有需要) + ↓ +Step 5: test-qa-agent → 全量回归测试 + ↓ +Step 6: doc-gen-agent → 文档更新 + ↓ +Step 7: package-release-agent → 打包发布 +``` + +### 9.3 可并行项 + +- F016 测试用例(Seq 3)和 F017 测试用例(Seq 2)可以在 F013 编码完成后**并行执行**,因为它们都依赖 F013 修复但不相互依赖。 +- F014/F015 确认(Seq 4)可以与测试用例并行。 + +--- + +## 10. 风险与缓解措施 + +| 风险 ID | 风险描述 | 影响 | 概率 | 缓解措施 | +|---------|---------|------|------|---------| +| R1 | Top 边自动检测逻辑误判(如模板 PinMAP 中 Name 和 Number 行都是空或非常规格式) | Top 边引脚仍然丢失 | 低 | 设置 70% 置信阈值 + 回退到默认行为;在检测失败时打印 WARN 日志 | +| R2 | 用户 PinMAP 的底部(Bottom)边也存在类似反转问题,但尚未反馈 | 底部引脚也丢失 | 中 | 如果 F017 测试发现底部也有问题,在 v1.6 中一并修复(扩大自动检测范围到下边) | +| R3 | 用户期望 PinMAP 布局与示例完全一致(像素级),但当前算法是均匀分配 | 用户不满意生成的 PinMAP 外观 | 中 | 在 F016 的实现中明确声明"往返正确性验证",布局一致性留到 v1.7 | +| R4 | F013 的修改改变了现有 4×4/12-pin 测试的预期行为 | 回归测试失败 | 低 | 自动检测会选择布局 A(与 v1.5.5 一致的 Name 在上方),对现有测试透明 | +| R5(已关闭) | ~~QFN60 12×12 网格周长(48)与 60 引脚不匹配~~ | — | — | ✅ 已确认 QFN60 是 15×15 网格,周长 = (15+15)×2 = 60,完美匹配 | + +--- + +## 11. v1.6 与 v1.5.5 代码差异预估 + +### 11.1 新增代码 + +| 位置 | 内容 | 行数 | +|------|------|------| +| `pinmap_parser.py` Step 2.5 | Top 边布局自动检测函数 `_detect_top_layout()` | ~30 行 | +| `pinmap_parser.py` Step 3 | 修改上边 Name 查找(替换现有 5 行) | +5 行 | +| `pinmap_parser.py` Step 4d | 修改上边遍历(替换现有 2 行) | +2 行 | +| `test_pinmap.py` | QFN60 cells 常量(~60 pins, 12×12) | ~80 行 | +| `test_pinmap.py` | F017 测试函数 (解析 + 转换 + 往返) | ~60 行 | +| `test_pinmap.py` | F016 测试函数 (生成 + 往返) | ~40 行 | + +### 11.2 预计不修改的文件 + +- `main.py` — 模板搜索路径已在 v1.5.5 修复(除非用户坚持改为项目根目录优先) +- `pinmap_layout.py` — List→MAP 生成布局不变 +- `pinmap_generator.py` — 无变化 +- `pinlist_generator.py` — 无变化 +- `template_reader.py` — 无变化 +- `xlsx_writer.py` — 无变化 +- `validator.py` — 无变化 +- `pinlist_parser.py` — 无变化 +- `pinlist_validator.py` — 无变化 + +### 11.3 总工作量预估 + +| 阶段 | 预估时间 | +|------|---------| +| 编码(F013) | 1.0 h | +| 测试(F016 + F017) | 0.75 h | +| 确认/修复(F014 + F015) | 0.25 h | +| 回归测试 | 0.25 h | +| 文档 | 0.25 h | +| 打包 | 0.25 h | +| **合计** | **~2.75 h** | + +--- + +## 12. 总结 + +1. **F013(最关键)**:`pinmap_parser.py` 硬编码假设 Top 边 Name 在 Number 上方一行,但用户真实 PinMAP 中 Name 在 Number 下方一行。需要增加基于数据内容的**自动布局检测**,兼容两种方向。修改集中在 `pinmap_parser.py` 一个文件。 + +2. **F014/F015(已有基础)**:模板搜索路径在 v1.5.5 中已修复(`Code/src/Template/`),样式应用链路完整。v1.6 主要是**确认性验证**,代码改动预计为零。 + +3. **F016/F017(新增测试)**:需要构建 QFN60 60-pin 的测试数据用于端到端验证。**核心关注往返正确性**(List→MAP→List 不丢失引脚),而非与用户示例的像素级布局一致(后者需要后续版本支持非均匀边分配)。 + +4. **最大风险 R5**:60 引脚与 12×12 网格的周长(48)不匹配。需要在测试实施阶段确认正确的行/列参数。如果用户 PinMAP 实际使用的是非标准布局(某些边有不同数量的引脚),可能需要向用户确认。 + +--- + +*文档结束 — v1.6 整改架构评估* diff --git a/docs/tasks.md b/docs/tasks.md index 8cbfba4..0847aff 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -26,4 +26,10 @@ | T025 | 架构评估 PinList→PinMAP 布局 Bug 修复 | script-architect | 已完成 | 架构评估 | BUG-005, BUG-006 | 2026-06-12 | 2026-06-12 | BUG-005/006 用户反馈 v1.5.4 修复未生效,需重新分析 | | T026 | 编码实现 v1.5.5 Bug 修复 | python-coding-agent | 已完成 | 编码实现 | BUG-005, BUG-006 | 2026-06-12 | 2026-06-12 | 全部 37 测试通过 | | T027 | 文档生成 v1.5.5 | doc-gen-agent | 已完成 | 文档编写 | BUG-005, BUG-006 | 2026-06-12 | 2026-06-12 | 文档已全部更新 | -| T028 | 打包发布 v1.5.5 | package-release-agent | 进行中 | 打包发布 | BUG-005, BUG-006 | 2026-06-12 | - | 创建 Release + 上传 zip | +| T028 | 打包发布 v1.5.5 | package-release-agent | 已完成 | 打包发布 | BUG-005, BUG-006 | 2026-06-12 | 2026-06-12 | Release 已创建 + zip 已上传 + git push 完成 | +| T029 | 架构评估 v1.6 整改(F013-F017) | script-architect | 已完成 | 架构评估 | F013-F017 | 2026-06-12 | 2026-06-12 | 评估完成,见 docs/modification-assessment-v1.6.md | +| T030 | 编码实现 F013 上方引脚丢失修复 | python-coding-agent | 已完成 | 编码实现 | F013 | 2026-06-12 | 2026-06-12 | pinmap_parser.py 增加自动布局检测 | +| T031 | 测试验证 F016/F017 | test-architect | 已完成 | 测试验证 | F016, F017 | 2026-06-12 | 2026-06-12 | 5 个 QFN60 新增测试 + 全量 23/23 通过 | +| T032 | 模板确认 F014/F015 | python-coding-agent | 已完成 | 确认验证 | F014, F015 | 2026-06-12 | 2026-06-12 | 两个模板文件存在,样式解析成功 | +| T033 | 文档生成 v1.6 | doc-gen-agent | 已完成 | 文档编写 | F013-F017 | 2026-06-12 | 2026-06-12 | 更新 CHANGELOG.md、features.md、tasks.md | +| T034 | 打包发布 v1.6 | package-release-agent | 待处理 | 打包发布 | F013-F017 | 2026-06-12 | - | 等待 package-release-agent 执行 | diff --git a/pinmap-to-pinlist-v1.6.0.zip b/pinmap-to-pinlist-v1.6.0.zip new file mode 100644 index 0000000..a1e0540 Binary files /dev/null and b/pinmap-to-pinlist-v1.6.0.zip differ