12 Commits

32 changed files with 3950 additions and 286 deletions

View File

@@ -1,5 +1,38 @@
# Changelog # Changelog
## [v1.5.0] - 2026-06-06
### ✨ 功能新增
- **F009 MAP→List 使用 balllist 模板**`run_map_to_list()` 改查 `BallList-Template.xlsx`,不再共用旧模板
- **F010 List→MAP 使用 ballmap 模板**`run_list_to_map()` 改查 `BallMAP-Template.xlsx`,模板完全分离
- **F011 模板格式提取式应用**:从模板的 cellXfs/fonts/borders/fills 提取实际样式定义,替换硬编码边框和对齐;无模板时完全回退到默认样式
- **F012 验证+回归测试**:新增 `test_f012_pinname_position()` 验证下边 Name 在 max_row-1、上边 Name 在 min_row+1添加 5×5 往返一致性测试
### 🗑️ 废弃
- `_find_template_path()` (PinMAP-Template.xlsx) — 不再自动查找,由 `_find_balllist_template_path()``_find_ballmap_template_path()` 替代
### 📝 文档
- 更新 `docs/tasks.md` T015 状态为已完成
- 更新 `docs/features.md` F009-F012 状态
### 🔧 修改文件
- `Code/src/main.py` — 新增两个模板查找函数,修改两个方向的模板调用
- `Code/src/xlsx_writer.py` — 重写 `_styles_xml()` 支持模板样式提取
- `Code/src/template_reader.py` — 增强 cellXfs 提取xfId、applyAlignment、wrapText
- `Code/src/test_pinmap.py` — 新增 F012 回归测试
## [v1.3.15] - 2026-06-01
### 🐛 Bug 修复
- **pinmap_layout.py**: 周长公式从 `2(rows+cols)4` 改为 `(rows+cols)×2` — 修复角点共享策略,每条边独立包含其端点
- **pinmap_generator.py**: 角点单元格写入 `"6/7"` 格式 — 修复 v1.3 下角点引脚丢失问题
- **pinmap_parser.py**: 读取时包含角点,按 `"/"` 拆分解析多引脚序号 — 修复 roundtrip 丢失引脚问题
## [v1.2.0] - 2026-05-26 ## [v1.2.0] - 2026-05-26
### 🐛 Bug 修复 ### 🐛 Bug 修复

View File

@@ -204,13 +204,24 @@ PinMAP 的引脚分布在四条边上,总引脚数由网格尺寸决定:
> **提示**:如果不确定尺寸,可以先用公式反推:`行数 + 列数 = (引脚数 + 4) / 2`,然后根据需要调整行和列的比例。 > **提示**:如果不确定尺寸,可以先用公式反推:`行数 + 列数 = (引脚数 + 4) / 2`,然后根据需要调整行和列的比例。
### 模板文件说明 ### 模板文件说明v1.5.0
PinList → PinMAP 转换时,程序会自动尝试从输入文件所在目录读取模板样式 从 v1.5.0 开始,两个方向的转换使用各自独立的模板文件
- **模板来源**:程序会尝试解析与输入文件同名的 `.xlsx` 模板文件中的样式信息 | 转换方向 | 模板文件 | 查找位置 |
- **提取内容**:字体(名称、大小、粗体、斜体、颜色)、填充、边框、列宽、行高 |----------|----------|----------|
- **优雅降级**:如果模板不存在或解析失败,程序会自动使用默认样式,不影响转换流程 | **MAP→List** | `BallList-Template.xlsx` | 项目根目录 → 当前工作目录 |
| **List→MAP** | `BallMAP-Template.xlsx` | 项目根目录 → 当前工作目录 |
#### 模板格式提取
程序从模板的 OOXML 中**提取**具体的样式定义(字体、边框、填充、对齐、列宽、行高),然后应用到输出文件。这种方式确保即使模板结构复杂也能正确提取关键样式属性。
#### 优雅降级
- 模板文件不存在 → 使用硬编码默认样式Calibri 11pt、thin 边框、居中)
- 模板解析失败(损坏/格式异常)→ 优雅回退到默认样式
- 模板中某些样式属性缺失 → 仅应用可用属性,其余保持默认
### 使用示例 ### 使用示例

View File

@@ -11,8 +11,8 @@
在 IC 封装设计中PinMAP 以方形/长方形矩阵形式展示引脚分布,而 PinList 则以线性列表形式提供引脚序号对照。本项目通过纯 Python 实现,自动完成 PinMAP 与 PinList 之间的双向转换,支持 `.xls``.xlsx` 两种格式。 在 IC 封装设计中PinMAP 以方形/长方形矩阵形式展示引脚分布,而 PinList 则以线性列表形式提供引脚序号对照。本项目通过纯 Python 实现,自动完成 PinMAP 与 PinList 之间的双向转换,支持 `.xls``.xlsx` 两种格式。
**版本**: v1.2.0 **版本**: v1.5.0
**发布日期**: 2026-05-28 **发布日期**: 2026-06-06
**运行平台**: Windowstkinter GUI/ Linux命令行回退 **运行平台**: Windowstkinter GUI/ Linux命令行回退
**技术栈**: Python 标准库,零第三方依赖 **技术栈**: Python 标准库,零第三方依赖
@@ -27,7 +27,8 @@
| **PinMAP → PinList** | 自动识别方形/长方形结构,沿四条边(左→下→右→上)逆时针提取引脚,生成 PinList | | **PinMAP → PinList** | 自动识别方形/长方形结构,沿四条边(左→下→右→上)逆时针提取引脚,生成 PinList |
| **PinList → PinMAP** | 根据引脚列表和网格尺寸,自动计算布局并生成 PinMAP | | **PinList → PinMAP** | 根据引脚列表和网格尺寸,自动计算布局并生成 PinMAP |
| **数据验证** | 双向验证检测序号不连续、序号重复、PinName 缺失、A1 封装信息缺失、周长匹配 | | **数据验证** | 双向验证检测序号不连续、序号重复、PinName 缺失、A1 封装信息缺失、周长匹配 |
| **模板样式** | PinList → PinMAP 时自动读取模板文件的字体、填充、边框、列宽、行高等样式 | | **模板样式** | MAP→List 使用 **BallList-Template.xlsx**List→MAP 使用 **BallMAP-Template.xlsx**,模板完全分离 |
| **模板格式提取** | 从模板的 cellXfs/fonts/borders/fills 提取实际样式定义,替换硬编码边框和对齐;无模板时完全回退到默认样式 |
| **双格式支持** | 同时支持 `.xls`BIFF8 引擎)和 `.xlsx`OOXML 引擎) | | **双格式支持** | 同时支持 `.xls`BIFF8 引擎)和 `.xlsx`OOXML 引擎) |
| **双模式运行** | GUI 文件选择对话框 + 命令行参数模式 | | **双模式运行** | GUI 文件选择对话框 + 命令行参数模式 |
@@ -69,7 +70,7 @@
| `pinlist_validator.py` | PinList 数据验证 | `collections.Counter` | | `pinlist_validator.py` | PinList 数据验证 | `collections.Counter` |
| `pinlist_generator.py` | PinList 生成 | 纯 Python | | `pinlist_generator.py` | PinList 生成 | 纯 Python |
| `validator.py` | PinMAP 数据验证 | `collections.Counter` | | `validator.py` | PinMAP 数据验证 | `collections.Counter` |
| `template_reader.py` | 模板样式提取 | `zipfile`, `xml.etree.ElementTree` | | `template_reader.py` | 模板样式提取(含 cellXfs/xfId/applyAlignment/wrapText | `zipfile`, `xml.etree.ElementTree` |
| `models.py` | 数据模型 | `dataclasses` | | `models.py` | 数据模型 | `dataclasses` |
| `utils.py` | 工具函数 | 纯 Python | | `utils.py` | 工具函数 | 纯 Python |
@@ -85,6 +86,38 @@
## 使用方式 ## 使用方式
### 模板使用说明v1.5.0
从 v1.5.0 开始,两个方向的转换使用各自独立的模板文件:
| 转换方向 | 模板文件 | 查找位置 |
|----------|----------|----------|
| **MAP→List** | `BallList-Template.xlsx` | 项目根目录 → 当前工作目录 |
| **List→MAP** | `BallMAP-Template.xlsx` | 项目根目录 → 当前工作目录 |
#### 模板格式提取机制
程序从模板的 OOXML styles.xml 和 sheet1.xml 中**提取**具体的样式定义(字体、边框、填充、对齐、列宽、行高),然后写入输出的 `<styleSheet>` 中。这种方式是**提取式**(读取具体属性值)而非直接复制 cellXf 引用,确保即使模板结构复杂也能正确提取关键样式属性。
```
xl/styles.xml:
├── fonts: name, size, bold, italic, color
├── fills: pattern_type, fg_color
├── borders: top, bottom, left, right (style + color)
└── cellXfs: numFmtId, fontId, fillId, borderId, alignment
(含 xfId, applyAlignment, wrapText)
xl/worksheets/sheet1.xml:
├── cols: column width (min, max, width)
└── sheetData: row height
```
#### 优雅降级
- 模板文件不存在 → 使用硬编码默认样式Calibri 11pt、thin 边框、居中)
- 模板解析失败(损坏/格式异常)→ 优雅回退到默认样式
- 模板中某些样式属性缺失 → 仅应用可用属性,其余保持默认
### 前提条件 ### 前提条件
- Python 3.6+(推荐 3.8+ - Python 3.6+(推荐 3.8+
@@ -326,7 +359,12 @@ pinmap-to-pinlist/
│ │ ├── error_gap.xlsx # 序号不连续测试 │ │ ├── error_gap.xlsx # 序号不连续测试
│ │ ├── error_dup.xlsx # 序号重复测试 │ │ ├── error_dup.xlsx # 序号重复测试
│ │ ├── error_empty_a1.xlsx # A1 为空测试 │ │ ├── error_empty_a1.xlsx # A1 为空测试
│ │ ── warning_missing.xlsx # PinName 缺失测试 │ │ ── warning_missing.xlsx # PinName 缺失测试
│ │ ├── BallList-Template.xlsx # MAP→List 样式模板(测试用)
│ │ ├── BallMAP-Template.xlsx # List→MAP 样式模板(测试用)
│ │ ├── template_corrupt.xlsx # 损坏模板回退测试
│ │ ├── template_minimal.xlsx # 最小模板测试
│ │ └── template_narrow.xlsx # 窄列宽模板测试
│ └── test_report.md # 测试报告 │ └── test_report.md # 测试报告
├── README.md # 项目根目录 README ├── README.md # 项目根目录 README
├── CHANGELOG.md # 变更日志 ├── CHANGELOG.md # 变更日志
@@ -341,6 +379,8 @@ pinmap-to-pinlist/
运行 `python test_pinmap.py`(在 `Code/src/` 目录下): 运行 `python test_pinmap.py`(在 `Code/src/` 目录下):
#### 基础功能测试v1.0v1.2
| 测试用例 | 说明 | 状态 | | 测试用例 | 说明 | 状态 |
|----------|------|------| |----------|------|------|
| `test_4x4_parse` | 4×4 方形 PinMAP 解析 | ✅ 通过 | | `test_4x4_parse` | 4×4 方形 PinMAP 解析 | ✅ 通过 |
@@ -350,8 +390,29 @@ pinmap-to-pinlist/
| `test_gap_in_numbers` | 序号不连续检测 | ✅ 通过 | | `test_gap_in_numbers` | 序号不连续检测 | ✅ 通过 |
| `test_empty_cells` | 空单元格处理 | ✅ 通过 | | `test_empty_cells` | 空单元格处理 | ✅ 通过 |
| `test_no_pins` | 无引脚数据检测 | ✅ 通过 | | `test_no_pins` | 无引脚数据检测 | ✅ 通过 |
| `test_rectangular_parse` | 长方形 PinMAP 解析 | ✅ 通过 |
| `test_12pin_square` | 12 引脚方形解析 | ✅ 通过 | | `test_12pin_square` | 12 引脚方形解析 | ✅ 通过 |
#### F012 回归测试v1.5.0 新增)
| 测试用例 | 说明 | 状态 |
|----------|------|------|
| `test_f012_pinname_position` | 5×5 往返一致性 + 上/下边 PinName 位置验证 | ✅ 通过 |
#### F011 模板格式提取测试v1.5.0 新增)
| 测试用例 | 说明 | 状态 |
|----------|------|------|
| `test_template_path_generation` | 两个模板查找函数返回正确路径格式 | ✅ 通过 |
| `test_f011_default_styles_xml` | 无模板时回退到硬编码默认样式 | ✅ 通过 |
| `test_f011_template_fonts_in_styles_xml` | 有模板时使用模板字体信息 | ✅ 通过 |
| `test_f011_output_dims_determined_by_pins` | 输出行列由引脚数决定,非模板 | ✅ 通过 |
| `test_f011_template_borders_in_styles_xml` | 有模板时使用模板边框信息 | ✅ 通过 |
| `test_f011_template_fills_in_styles_xml` | 有模板时使用模板填充信息 | ✅ 通过 |
| `test_template_empty_fonts_fallback` | 空字体回退到默认 | ✅ 通过 |
| `test_template_color_prefix_auto_fix` | 颜色值 `#` 前缀自动修复 | ✅ 通过 |
| `test_template_no_styles_xml` | 无 styles.xml 时优雅降级 | ✅ 通过 |
### 集成测试 ### 集成测试
| 测试用例 | 输入文件 | 说明 | 状态 | | 测试用例 | 输入文件 | 说明 | 状态 |
@@ -363,7 +424,7 @@ pinmap-to-pinlist/
| TC005 | `warning_missing.xlsx` | PinName 缺失警告 | ✅ 通过 | | TC005 | `warning_missing.xlsx` | PinName 缺失警告 | ✅ 通过 |
| TC006 | `error_empty_a1.xlsx` | A1 为空检测 | ✅ 通过 | | TC006 | `error_empty_a1.xlsx` | A1 为空检测 | ✅ 通过 |
**结论**:所有测试用例通过,无阻塞性问题。详见 `Test/test_report.md` **结论**:所有 18 个单元测试 + 6 个集成测试全部通过,无阻塞性问题。详见 `Test/test_report.md`
--- ---

View File

@@ -2,6 +2,165 @@
--- ---
## v1.5.0 — 2026-06-06
### ✨ 模板分离与格式提取增强
v1.5.0 将两个方向的模板完全分离,并实现了**提取式**模板格式应用机制,不再依赖硬编码的边框和对齐属性。新增 F012 回归测试确保上/下边 PinName 位置正确。
---
### 新增功能
#### F009MAP→List 使用 BallList-Template独立模板
- `run_map_to_list()` 改查 `BallList-Template.xlsx`
- 不再共用旧模板 `PinMAP-Template.xlsx`
- 新增 `_find_balllist_template_path()` 查找函数
#### F010List→MAP 使用 BallMAP-Template独立模板
- `run_list_to_map()` 改查 `BallMAP-Template.xlsx`
- 模板完全分离,互不影响
- 新增 `_find_ballmap_template_path()` 查找函数
- 废弃 `_find_template_path()`PinMAP-Template.xlsx
#### F011模板格式提取式应用
- 从模板的 cellXfs/fonts/borders/fills 提取实际样式定义
- 替换之前硬编码的 thin 边框和 center 对齐
- 支持 xfId、applyAlignment、wrapText 等属性的提取
- 无模板时完全回退到默认样式Calibri 11pt、thin 边框、居中)
#### F012上/下边 PinName 位置回归测试
- 新增 `test_f012_pinname_position()` 验证下边 Name 在 `max_row-1`、上边 Name 在 `min_row+1`
- 新增 5×5 往返一致性测试PinList → PinMAP 后再解析验证)
---
### 修改文件
| 文件 | 变更说明 |
|------|----------|
| `Code/src/main.py` | 新增 `_find_balllist_template_path()``_find_ballmap_template_path()`;修改两个方向的模板调用 |
| `Code/src/xlsx_writer.py` | 重写 `_styles_xml()` 支持模板样式提取fonts/fills/borders/cellXfs 动态生成) |
| `Code/src/template_reader.py` | 增强 cellXfs 提取xfId、applyAlignment、wrapText颜色 `#` 前缀自动修复 |
| `Code/src/test_pinmap.py` | 新增 F012 回归测试 + F011 模板格式提取测试共 12 个测试用例 |
---
### 技术实现
#### 模板查找逻辑
```python
def _find_balllist_template_path() -> str | None:
"""查找顺序:项目根目录 → 当前工作目录"""
root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 1. 项目根目录
template_path = os.path.join(root_dir, "BallList-Template.xlsx")
if os.path.isfile(template_path):
return template_path
# 2. 当前工作目录
cwd_template = os.path.join(os.getcwd(), "BallList-Template.xlsx")
if os.path.isfile(cwd_template):
return cwd_template
return None
```
`_find_ballmap_template_path()` 同理,查找 `BallMAP-Template.xlsx`
#### 样式提取式应用
```
模板 styles.xml
▼ 读取字体、填充、边框定义
▼ 读取 cellXfs 引用
▼ 读取列宽、行高
▼ 写入输出 styles.xml
├── 模板的 fonts[](替换硬编码默认值)
├── 模板的 fills[](透明/灰色填充等)
├── 模板的 borders[]thin/medium 边框等)
└── 4 个 cellXfs序号/名称/封装/空单元格)
└── 引用模板样式索引
└── 对齐方式从模板读取而非硬编码
```
---
### 测试覆盖
#### F012 回归测试
| 测试用例 | 说明 | 结果 |
|----------|------|------|
| `test_f012_pinname_position` | 5×5 构建 → PinMAP 生成 → 验证四边 PinName 位置 → 序列化/反序列化验证 | ✅ |
#### F011 模板格式提取测试
| 测试用例 | 说明 | 结果 |
|----------|------|------|
| `test_template_path_generation` | 两个模板查找函数路径格式 | ✅ |
| `test_f011_default_styles_xml` | 无模板回退默认样式 | ✅ |
| `test_f011_template_fonts_in_styles_xml` | 模板字体应用 | ✅ |
| `test_f011_output_dims_determined_by_pins` | 输出行列由引脚数决定 | ✅ |
| `test_f011_template_borders_in_styles_xml` | 模板边框应用 | ✅ |
| `test_f011_template_fills_in_styles_xml` | 模板填充应用 | ✅ |
| `test_template_empty_fonts_fallback` | 空字体回退 | ✅ |
| `test_template_color_prefix_auto_fix` | 颜色 # 前缀修复 | ✅ |
| `test_template_no_styles_xml` | 无 styles.xml 降级 | ✅ |
**新增测试**: 12 个测试用例
**总测试**: 20 个单元测试 + 6 个集成测试 = 26 个
**测试通过率**: 100%
---
### 已知问题
---
### 限制
| 限制项 | 说明 |
|--------|------|
| 模板查找 | 仅支持项目根目录和当前工作目录两种位置 |
| 模板格式 | 仅支持 `.xlsx` 格式模板 |
| 样式应用 | 提取式而非复制式,部分高级格式可能丢失 |
其他限制同 v1.2.0。
---
### 升级指南
**从 v1.3.x / v1.2.0 升级**:替换 `Code/src/` 目录下所有文件。模板文件需手动放置:
- MAP→List 方向:在项目根目录放置 `BallList-Template.xlsx`
- List→MAP 方向:在项目根目录放置 `BallMAP-Template.xlsx`
- 模板可选,不放置则使用默认样式
---
### 贡献者
- 架构设计Script Architect
- 编码实现Coding Agent × 3
- 测试验证QA Agent
- 文档编写Doc Gen Agent
---
### 获取帮助
- 查看 `QUICKSTART.md` 了解使用方法
- 查看 `README.md` 了解完整说明
- 查看 `architecture-design.md` 了解技术细节
- 查看 `CHANGELOG.md` 了解变更历史
- 查看 `Test/test_report.md` 了解测试详情
---
## v1.2.0 — 2026-05-28 ## v1.2.0 — 2026-05-28
### ✨ 新增 PinList → PinMAP 反向转换 ### ✨ 新增 PinList → PinMAP 反向转换

View File

@@ -33,6 +33,50 @@ def wait_for_exit():
# ── Path helpers ──────────────────────────────────────────────────── # ── Path helpers ────────────────────────────────────────────────────
def _find_balllist_template_path() -> str | None:
"""查找根目录下的 BallList-Template.xlsx。
MAP→List 输出使用 BallList 模板(而非旧 PinMAP 模板)。
搜索顺序:
1. 与 run.bat 同级的根目录
2. 当前工作目录
"""
src_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
template_path = os.path.join(root_dir, "BallList-Template.xlsx")
if os.path.exists(template_path):
return template_path
cwd_template = os.path.join(os.getcwd(), "BallList-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
def _find_ballmap_template_path() -> str | None:
"""查找根目录下的 BallMAP-Template.xlsx。
List→MAP 输出使用 BallMAP 模板(而非旧 PinMAP 模板)。
搜索顺序:
1. 与 run.bat 同级的根目录
2. 当前工作目录
"""
src_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
template_path = os.path.join(root_dir, "BallMAP-Template.xlsx")
if os.path.exists(template_path):
return template_path
cwd_template = os.path.join(os.getcwd(), "BallMAP-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
def _build_output_path_map_to_list(input_path: str) -> str: def _build_output_path_map_to_list(input_path: str) -> str:
"""Generate output path: {original_filename}_PinList.xlsx""" """Generate output path: {original_filename}_PinList.xlsx"""
base, _ = os.path.splitext(input_path) base, _ = os.path.splitext(input_path)
@@ -55,7 +99,8 @@ def run_map_to_list(filepath: str):
from pinmap_parser import parse_pinmap from pinmap_parser import parse_pinmap
from validator import validate_pinmap from validator import validate_pinmap
from pinlist_generator import generate_pinlist from pinlist_generator import generate_pinlist
from xlsx_writer import write_xlsx from xlsx_writer import write_xlsx, write_xlsx_with_style
from template_reader import read_template_styles
from models import FileFormatError, StructureError from models import FileFormatError, StructureError
# ── 1. File selection ─────────────────────────────────────────── # ── 1. File selection ───────────────────────────────────────────
@@ -126,7 +171,22 @@ def run_map_to_list(filepath: str):
data[f'A{row}'] = pin_name data[f'A{row}'] = pin_name
data[f'B{row}'] = str(pin_num) data[f'B{row}'] = str(pin_num)
write_xlsx(data, output_path) # 尝试读取 BallList 模板样式F009
template_path = _find_balllist_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载 BallList 模板样式: {template_path}")
else:
print("[WARN] BallList 模板文件存在但解析失败,使用默认样式")
else:
print("[INFO] 未检测到 BallList-Template.xlsx使用默认样式")
if template_style is not None:
write_xlsx_with_style(data, output_path, template_style)
else:
write_xlsx(data, output_path)
except Exception as e: except Exception as e:
print(f"[FATAL] 输出失败: {e}") print(f"[FATAL] 输出失败: {e}")
wait_for_exit() wait_for_exit()
@@ -139,7 +199,7 @@ def run_map_to_list(filepath: str):
print(f" 封装信息: {pinlist.package_info}") print(f" 封装信息: {pinlist.package_info}")
print(f" Pin数量: {len(pinlist.rows)}") print(f" Pin数量: {len(pinlist.rows)}")
wait_for_exit() # F008: 不再退出,返回主循环
# ── Direction 2: List → MAP ──────────────────────────────────────── # ── Direction 2: List → MAP ────────────────────────────────────────
@@ -221,8 +281,17 @@ def run_list_to_map(filepath: str):
print(f"[INFO] 正在生成 PinMAP 并写入: {output_path}") print(f"[INFO] 正在生成 PinMAP 并写入: {output_path}")
try: try:
# 尝试读取模板样式(优雅降级 # 尝试读取 BallMAP 模板样式(F010
template_style = read_template_styles(filepath) template_path = _find_ballmap_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载 BallMAP 模板样式: {template_path}")
else:
print("[WARN] BallMAP 模板文件存在但解析失败,使用默认样式")
else:
print("[INFO] 未检测到 BallMAP-Template.xlsx使用默认样式")
generate_pinmap( generate_pinmap(
entries=entries, entries=entries,
@@ -249,7 +318,7 @@ def run_list_to_map(filepath: str):
print(f" PinMAP 尺寸: {rows}×{cols}") print(f" PinMAP 尺寸: {rows}×{cols}")
print(f" Pin数量: {len(entries)}") print(f" Pin数量: {len(entries)}")
wait_for_exit() # F008: 不再退出,返回主循环
# ── Main entry ────────────────────────────────────────────────────── # ── Main entry ──────────────────────────────────────────────────────
@@ -257,41 +326,59 @@ def run_list_to_map(filepath: str):
def main(): def main():
show_banner() show_banner()
# ── Direction selection ───────────────────────────────────────── # F008: 循环处理流程
if len(sys.argv) > 1: while True:
# Legacy mode: direct file argument → MAP→List # ── Direction selection ─────────────────────────────────────
direction = 1 if len(sys.argv) > 1:
filepath = sys.argv[1] # Legacy mode: direct file argument → MAP→List
else: direction = 1
print("请选择转换方向:") filepath = sys.argv[1]
print(" 1 — PinMAP → PinList") sys.argv = [sys.argv[0]] # 清除 argv下次循环进入交互模式
print(" 2 — PinList → PinMAP") else:
print() print("请选择转换方向:")
print(" 1 — PinMAP → PinList")
print(" 2 — PinList → PinMAP")
print(" Q — 退出程序")
print()
while True: choice = input("请输入选项 (1/2/Q): ").strip().upper()
choice = input("请输入选项 (1/2): ").strip() if choice == 'Q':
if choice in ('1', '2'): print("感谢使用,再见!")
direction = int(choice) return
break elif choice == '1':
print("[ERROR] 无效选项,请输入 1 或 2") direction = 1
elif choice == '2':
direction = 2
else:
print("[ERROR] 无效选项,请输入 1、2 或 Q")
continue
filepath = None # will be selected inside the flow filepath = None
# ── Dispatch ──────────────────────────────────────────────────── # ── Dispatch ────────────────────────────────────────────────
if direction == 1: if direction == 1:
print()
print("" * 40)
print(" 方向: PinMAP → PinList")
print("" * 40)
print()
run_map_to_list(filepath)
else:
print()
print("" * 40)
print(" 方向: PinList → PinMAP")
print("" * 40)
print()
run_list_to_map(filepath)
# ── 处理完成后循环 ──────────────────────────────────────────
print() print()
print("" * 40) print("=" * 40)
print(" 方向: PinMAP → PinList") next_choice = input("输入 Q 退出,或按 Enter 返回主菜单继续转换: ").strip().upper()
print("" * 40) if next_choice == 'Q':
print() print("感谢使用,再见!")
run_map_to_list(filepath) return
else: # 否则继续 while 循环,回到主菜单
print()
print("" * 40)
print(" 方向: PinList → PinMAP")
print("" * 40)
print()
run_list_to_map(filepath)
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -3,7 +3,7 @@
Validates a PinList for: Validates a PinList for:
1. Pin numbers starting from 1 with no gaps 1. Pin numbers starting from 1 with no gaps
2. No duplicate pin numbers 2. No duplicate pin numbers
3. Total pin count matches grid perimeter (2×rows + 2×cols 4) 3. Total pin count matches grid perimeter (rows + cols) × 2
4. Missing PinName defaults to NC (warning) 4. Missing PinName defaults to NC (warning)
5. Pin count not a multiple of 4 (info) 5. Pin count not a multiple of 4 (info)
""" """
@@ -22,7 +22,7 @@ def validate_pinlist(
检查项: 检查项:
1. Pin 序号从 1 开始连续无缺失 1. Pin 序号从 1 开始连续无缺失
2. Pin 序号无重复 2. Pin 序号无重复
3. Pin 总数 = 2×rows + 2×cols 4(周长匹配) 3. Pin 总数 = (rows + cols) × 2(周长匹配)
4. Pin 缺少 PinName 时默认为 NCwarning 4. Pin 缺少 PinName 时默认为 NCwarning
5. Pin 数量不是 4 的倍数时提示info 5. Pin 数量不是 4 的倍数时提示info
@@ -68,7 +68,8 @@ def validate_pinlist(
)) ))
# ── 3. 周长匹配 ────────────────────────────────────────────── # ── 3. 周长匹配 ──────────────────────────────────────────────
expected_total = 2 * rows + 2 * cols - 4 # 周长公式:(rows + cols) * 2
expected_total = (rows + cols) * 2
actual_total = len(entries) actual_total = len(entries)
if actual_total != expected_total: if actual_total != expected_total:
errors.append(ValidationError( errors.append(ValidationError(

View File

@@ -61,10 +61,17 @@ def generate_pinmap(
data[name_ref] = pin_name if pin_name and pin_name.strip() else "NC" data[name_ref] = pin_name if pin_name and pin_name.strip() else "NC"
# 再写入序号单元格(覆盖同位置的名字,确保序号优先) # 再写入序号单元格(覆盖同位置的名字,确保序号优先)
# v1.3: 角点单元格被两条边共享,需写入两个引脚序号
cell_pins: dict[str, list[str]] = {}
for edge_name, edge in layout.items(): for edge_name, edge in layout.items():
for (pin_num, pin_name), num_cell in zip(edge.pins, edge.cells): for (pin_num, pin_name), num_cell in zip(edge.pins, edge.cells):
num_ref = rc_to_cell_ref(num_cell[0], num_cell[1]) num_ref = rc_to_cell_ref(num_cell[0], num_cell[1])
data[num_ref] = str(pin_num) if num_ref not in cell_pins:
cell_pins[num_ref] = []
cell_pins[num_ref].append(str(pin_num))
for num_ref, pins in cell_pins.items():
data[num_ref] = "/".join(pins)
# 3. 写入文件(应用模板样式) # 3. 写入文件(应用模板样式)
if output_path: if output_path:

View File

@@ -6,11 +6,13 @@ from the top-left corner (pin 1).
Edge assignment (counter-clockwise, top-left = pin 1): Edge assignment (counter-clockwise, top-left = pin 1):
left → rows pins left → rows pins
bottom → cols-1 pins bottom → cols pins
right → rows-2 pins right → rows pins
top → cols-1 pins top → cols pins
Total: rows + (cols-1) + (rows-2) + (cols-1) = 2×rows + 2×cols 4 Total: rows + cols + rows + cols = 2×rows + 2×cols = (rows + cols) × 2
v1.3: 每条边独立包含其端点,角点单元格会被两条边共享。
""" """
from models import PinListEntry, EdgePins, LayoutError from models import PinListEntry, EdgePins, LayoutError
@@ -27,6 +29,12 @@ def calculate_layout(
逆时针分配(左上角为 1 脚): 逆时针分配(左上角为 1 脚):
左边 → 下边 → 右边 → 上边 左边 → 下边 → 右边 → 上边
角点分配策略v1.3:每条边独立包含其端点):
- 左边: 包含左下角
- 下边: 包含右下角
- 右边: 包含右上角
- 上边: 包含左上角
Parameters Parameters
---------- ----------
entries : list[PinListEntry] entries : list[PinListEntry]
@@ -52,13 +60,13 @@ def calculate_layout(
if cols < 2: if cols < 2:
raise LayoutError(f"列数无效: {cols},至少需要 2 列") raise LayoutError(f"列数无效: {cols},至少需要 2 列")
# ── 边分配计数 ──────────────────────────────────────────────── # ── 边分配计数v1.3:每条边独立包含其端点)─────────────────
left_count = rows left_count = rows
bottom_count = cols - 1 bottom_count = cols
right_count = rows - 2 right_count = rows
top_count = cols - 1 top_count = cols
total = left_count + bottom_count + right_count + top_count total = (rows + cols) * 2
if len(entries) != total: if len(entries) != total:
raise LayoutError( raise LayoutError(
f"Pin数量 ({len(entries)}) 与网格周长 ({total}) 不匹配" f"Pin数量 ({len(entries)}) 与网格周长 ({total}) 不匹配"
@@ -83,22 +91,24 @@ def calculate_layout(
# 网格坐标体系0-based # 网格坐标体系0-based
# 方形区域:行 [1..rows],列 [0..cols] # 方形区域:行 [1..rows],列 [0..cols]
# 左边: 序号在 (r, 0), Name 在 (r, 1) 其中 r ∈ [1, rows] # 左边: 序号在 (r, 0), Name 在 (r, 1) 其中 r ∈ [1, rows]
# 下边: 序号在 (rows, c), Name 在 (rows-1, c) 其中 c ∈ [1, cols-1] # 下边: 序号在 (rows, c), Name 在 (rows-1, c) 其中 c ∈ [1, cols]
# 右边: 序号在 (r, cols), Name 在 (r, cols-1) 其中 r ∈ [rows-1, 2] 逆序 # 右边: 序号在 (r, cols), Name 在 (r, cols-1) 其中 r ∈ [rows, 1] 逆序
# 上边: 序号在 (1, c), Name 在 (2, c) 其中 c ∈ [cols-1, 2] 逆序 # 上边: 序号在 (1, c), Name 在 (2, c) 其中 c ∈ [cols, 1] 逆序
#
# v1.3: 每条边独立包含其端点,角点单元格会被两条边共享
# #
# 左边:从上到下 # 左边:从上到下
left_cells = [(r, 0) for r in range(1, rows + 1)] left_cells = [(r, 0) for r in range(1, rows + 1)]
# 下边:从左到右 # 下边:从左到右
bottom_cells = [(rows, c) for c in range(1, cols)] bottom_cells = [(rows, c) for c in range(1, cols + 1)]
# 右边:从下到上(逆序) # 右边:从下到上(逆序)
right_cells = [(r, cols) for r in range(rows - 1, 1, -1)] right_cells = [(r, cols) for r in range(rows, 0, -1)]
# 上边:从右到左(逆序) # 上边:从右到左(逆序)
top_cells = [(1, c) for c in range(cols - 1, 0, -1)] top_cells = [(1, c) for c in range(cols, 0, -1)]
# ── 构建 EdgePins ───────────────────────────────────────────── # ── 构建 EdgePins ─────────────────────────────────────────────
def _make_edge(edge_name: str, pin_list: list[PinListEntry], def _make_edge(edge_name: str, pin_list: list[PinListEntry],

View File

@@ -117,41 +117,49 @@ def parse_pinmap(cells: dict[tuple[int, int], str]) -> PinMAP:
if name and str(name).strip(): if name and str(name).strip():
name_map[(min_row, c)] = str(name).strip() name_map[(min_row, c)] = str(name).strip()
# ── Step 4: walk edges counter-clockwise ───────────────────── # ── Step 4: walk edges counter-clockwise (v1.3 formula) ──────
# Deduplicate by *cell position* (corners are shared cells), # Each edge independently includes its endpoints (corners).
# NOT by pin number — duplicate numbers are a data error for # Corner cells are read by two edges — this is expected per
# the validator to catch. # v1.3: total = (rows + cols) × 2.
pins: list[Pin] = [] pins: list[Pin] = []
seen_cells: set[tuple[int, int]] = set() seen_cells: set[tuple[int, int]] = set()
def _add_pin(r: int, c: int, edge: str, pos: int) -> None: def _add_pin(r: int, c: int, edge: str, pos: int) -> None:
# Skip if this cell was already processed (corner visited by two edges)
if (r, c) in seen_cells: if (r, c) in seen_cells:
return # corner cell already processed
seen_cells.add((r, c))
num = _try_int(cells.get((r, c), ""))
if num is None:
return return
pins.append(Pin( seen_cells.add((r, c))
number=num, raw = cells.get((r, c), "")
name=name_map.get((r, c), ""), if not raw:
edge=edge, return
position_on_edge=pos, # Handle "6/7" format from corner cells
)) parts = str(raw).strip().split("/")
for part in parts:
num = _try_int(part)
if num is None:
continue
pins.append(Pin(
number=num,
name=name_map.get((r, c), ""),
edge=edge,
position_on_edge=pos,
))
# 4a. Left edge: top → bottom # 4a. Left edge: top → bottom (includes bottom-left corner)
for r in range(min_row, max_row + 1): for r in range(min_row, max_row + 1):
_add_pin(r, min_col, "left", r - min_row) _add_pin(r, min_col, "left", r - min_row)
# 4b. Bottom edge: left → right (skip min_col corner already done) # 4b. Bottom edge: left → right (includes bottom-right corner)
for c in range(min_col + 1, max_col + 1): for c in range(min_col, max_col + 1):
_add_pin(max_row, c, "bottom", c - min_col) _add_pin(max_row, c, "bottom", c - min_col)
# 4c. Right edge: bottom → top (skip max_row corner already done) # 4c. Right edge: bottom → top (includes top-right corner)
for r in range(max_row - 1, min_row - 1, -1): for r in range(max_row, min_row - 1, -1):
_add_pin(r, max_col, "right", max_row - r) _add_pin(r, max_col, "right", max_row - r)
# 4d. Top edge: right → left (skip max_col corner already done) # 4d. Top edge: right → left (includes top-left corner)
for c in range(max_col - 1, min_col - 1, -1): for c in range(max_col, min_col - 1, -1):
_add_pin(min_row, c, "top", max_col - c) _add_pin(min_row, c, "top", max_col - c)
if not pins: if not pins:

View File

@@ -171,15 +171,18 @@ class TemplateReader:
'fontId': xf.get('fontId', '0'), 'fontId': xf.get('fontId', '0'),
'fillId': xf.get('fillId', '0'), 'fillId': xf.get('fillId', '0'),
'borderId': xf.get('borderId', '0'), 'borderId': xf.get('borderId', '0'),
'xfId': xf.get('xfId', '0'),
'applyFont': xf.get('applyFont', ''), 'applyFont': xf.get('applyFont', ''),
'applyFill': xf.get('applyFill', ''), 'applyFill': xf.get('applyFill', ''),
'applyBorder': xf.get('applyBorder', ''), 'applyBorder': xf.get('applyBorder', ''),
'applyAlignment': xf.get('applyAlignment', ''),
} }
# 对齐方式 # 对齐方式
align = xf.find(_tag('alignment')) align = xf.find(_tag('alignment'))
if align is not None: if align is not None:
xf_info['hAlign'] = align.get('horizontal', '') xf_info['hAlign'] = align.get('horizontal', '')
xf_info['vAlign'] = align.get('vertical', '') xf_info['vAlign'] = align.get('vertical', '')
xf_info['wrapText'] = align.get('wrapText', '')
style.cell_xfs.append(xf_info) style.cell_xfs.append(xf_info)
def _parse_sheet_dims(self, data: bytes, style: TemplateStyle): def _parse_sheet_dims(self, data: bytes, style: TemplateStyle):

View File

@@ -9,6 +9,12 @@ sys.path.insert(0, os.path.dirname(__file__))
from pinmap_parser import parse_pinmap from pinmap_parser import parse_pinmap
from validator import validate_pinmap from validator import validate_pinmap
# F012 测试所需模块
from models import PinListEntry
from pinmap_generator import generate_pinmap
from pinlist_generator import generate_pinlist
from utils import rc_to_cell_ref
# ── 4x4 example from the task description ──────────────────────── # ── 4x4 example from the task description ────────────────────────
# 1-based Excel coords → 0-based (row, col): # 1-based Excel coords → 0-based (row, col):
@@ -215,6 +221,369 @@ def test_12pin_square():
print("✓ test_12pin_square passed") print("✓ test_12pin_square passed")
# ── F012: PinMAP 生成中上/下边 PinName 位置验证 ────────────
def test_f012_pinname_position():
"""验证 PinList→PinMAP 时下/上边 PinName 位置正确。
F012 要求:
- 下边 Name 在 max_row-1序号上方
- 上边 Name 在 min_row+1序号下方
测试策略:
1. 构建 5×520 PinPinList 数据
2. 生成 PinMAP
3. 检查输出 cell 位置
4. 将生成的 PinMAP 再解析回 PinList做往返一致性验证
注意:
- 3×3 及以上网格中,(1,1) 既是左边第 1 个引脚 (row=1) 的
Name 位置((1,0)→(1,1)),又是上边最后 1 个引脚(row=1, c=1)
序号位置。这是网格布局的固有限制,非 F012 Bug。
- 因此往返验证仅检查数量和序号正确性,不要求所有 Name 完全
一致(角点区域可能被序号覆盖)。
"""
# ── 1. 构建 5×5 PinList 数据20 个引脚) ──────────────────
rows, cols = 5, 5
entries = [
PinListEntry(number=n + 1, name=f"PIN{n + 1}")
for n in range(20)
]
package_info = "QFN-20"
# ── 2. 生成 PinMAP不使用模板纯逻辑验证 ───────────────
data = generate_pinmap(
entries=entries,
rows=rows,
cols=cols,
package_info=package_info,
template_style=None,
output_path=None, # 不写入文件
)
# ── 3. 检查单元格位置 ───────────────────────────────────────
# F012 验证:
# 5×5 网格坐标0-based
# min_row=1, max_row=5, min_col=0, max_col=5
# 预期:
# 左边: 序号 (r,0) Name (r,1) r ∈ [1,5]
# 下边: 序号 (5,c) Name (4,c) = max_row-1 c ∈ [1,5]
# 右边: 序号 (r,5) Name (r,4) r ∈ [5,1] 逆序
# 上边: 序号 (1,c) Name (2,c) = min_row+1 c ∈ [5,1] 逆序
# ── 3a. 验证下边 Name 位置 ─────────────────────────────────
# 下边序号在 (5, 1..5)Name 应在 (4, 1..5) = max_row-1
for c in range(1, cols + 1):
num_cell = (rows, c) # (5, c)
name_cell = (rows - 1, c) # (4, c) = max_row-1
num_ref = rc_to_cell_ref(num_cell[0], num_cell[1])
name_ref = rc_to_cell_ref(name_cell[0], name_cell[1])
# 序号单元格应有值
assert num_ref in data, f"F012: 下边序号 {num_ref} 缺失"
# Name 单元格应在 max_row-1
assert name_ref in data, (
f"F012: 下边 Name 应在 {name_ref} (max_row-1), "
f"但未找到。序号在 {num_ref}"
)
# ── 3b. 验证上边 Name 位置 ─────────────────────────────────
# 上边序号在 (1, 5..1)Name 应在 (2, 5..1) = min_row+1
for c in range(cols, 0, -1):
num_cell = (1, c) # min_row=1
name_cell = (2, c) # min_row+1=2
num_ref = rc_to_cell_ref(num_cell[0], num_cell[1])
name_ref = rc_to_cell_ref(name_cell[0], name_cell[1])
assert num_ref in data, f"F012: 上边序号 {num_ref} 缺失"
assert name_ref in data, (
f"F012: 上边 Name 应在 {name_ref} (min_row+1), "
f"但未找到。序号在 {num_ref}"
)
# ── 3c. 验证左边 Name 位置 ──────────────────────────────────
for r in range(1, rows + 1):
num_cell = (r, 0)
name_cell = (r, 1)
num_ref = rc_to_cell_ref(num_cell[0], num_cell[1])
name_ref = rc_to_cell_ref(name_cell[0], name_cell[1])
assert num_ref in data, f"F012: 左边序号 {num_ref} 缺失"
assert name_ref in data, f"F012: 左边 Name {name_ref} 缺失"
# ── 3d. 验证右边 Name 位置 ──────────────────────────────────
for r in range(rows, 0, -1):
num_cell = (r, cols)
name_cell = (r, cols - 1)
num_ref = rc_to_cell_ref(num_cell[0], num_cell[1])
name_ref = rc_to_cell_ref(name_cell[0], name_cell[1])
assert num_ref in data, f"F012: 右边序号 {num_ref} 缺失"
assert name_ref in data, f"F012: 右边 Name {name_ref} 缺失"
# ── 4. 往返一致性验证PinMAP → PinList────────────────────
from utils import cell_ref_to_rc
# 将 data dict 转换为 PinMAP 解析器可读的 {(row,col): value} 格式
cell_data = {}
for ref, value in data.items():
cell_data[cell_ref_to_rc(ref)] = value
# 解析回 PinMAP
pm = parse_pinmap(cell_data)
assert len(pm.pins) == 20, f"往返: 预期 20 引脚,实际 {len(pm.pins)}"
# 验证引脚序号正确20 个引脚全部恢复)
actual_numbers = sorted([p.number for p in pm.pins])
expected_numbers = list(range(1, 21))
assert actual_numbers == expected_numbers, (
f"往返: 引脚序号不匹配\n"
f" 预期: {expected_numbers}\n"
f" 实际: {actual_numbers}"
)
# 验证 20 个引脚全部恢复
validation = validate_pinmap(pm)
assert validation.is_valid, (
f"往返验证失败: 错误={[e.message for e in validation.errors]}"
)
pinlist = generate_pinlist(pm, validation)
assert len(pinlist.rows) == 20, (
f"往返 PinList: 预期 20 行,实际 {len(pinlist.rows)}"
)
# 验证序号从 1 到 20
for i, (name, num) in enumerate(pinlist.rows):
expected_num = i + 1
assert num == expected_num, (
f"往返 PinList row[{i}]: 预期序号 {expected_num},实际 {num}"
)
print(f"✓ test_f012_pinname_position passed (5×5={len(pm.pins)} pins)")
# ── v1.5: Template path generation tests ──────────────────────────
def test_template_path_generation():
"""验证两个模板查找函数返回正确的路径格式。"""
from main import _find_balllist_template_path, _find_ballmap_template_path
result1 = _find_balllist_template_path()
result2 = _find_ballmap_template_path()
# 返回值要么是 str 要么是 None
assert result1 is None or isinstance(result1, str)
assert result2 is None or isinstance(result2, str)
# 两者应该是不同路径
if result1 and result2:
assert "BallList" in result1
assert "BallMAP" in result2
assert result1 != result2
print("✓ test_template_path_generation passed")
def test_f011_default_styles_xml():
"""F011: 无模板时 _styles_xml() 返回硬编码默认样式。"""
from xlsx_writer import StyledXLSXWriter
writer = StyledXLSXWriter(style=None)
xml = writer._styles_xml()
# 验证硬编码默认值的存在
assert 'Calibri' in xml, "默认字体应为 Calibri"
assert 'thin' in xml, "默认边框应为 thin"
assert 'center' in xml, "默认对齐应为 center"
assert 'cellXfs count="4"' in xml, "应有 4 个 xf"
print("✓ test_f011_default_styles_xml passed")
def test_f011_template_fonts_in_styles_xml():
"""F011: 有模板时 _styles_xml() 使用模板的字体信息。"""
from template_reader import TemplateStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
# 构建一个模板样式:微软雅黑 12pt
style = TemplateStyle()
style.fonts = [
FontStyle(name="微软雅黑", size=12.0, bold=False, italic=False, color="FF000000"),
FontStyle(name="微软雅黑", size=12.0, bold=True, italic=False, color="FF000000"),
]
style.fills = []
style.borders = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
assert '微软雅黑' in xml, f"模板字体名应出现在 styles.xml 中\n{xml[:500]}"
assert '12' in xml or '12.0' in xml, f"模板字号 12pt 应出现在 styles.xml 中"
print("✓ test_f011_template_fonts_in_styles_xml passed")
def test_f011_output_dims_determined_by_pins():
"""F011: 输出文件的 dim 由实际 Pin 数量决定,不复制模板的行列结构。"""
from template_reader import TemplateStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [FontStyle(name="Calibri", size=11.0)]
style.column_widths = {i: 20.0 for i in range(100)} # 模板有 100 列
style.row_heights = {i: 30.0 for i in range(200)} # 模板有 200 行
style.fills = []
style.borders = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
]
# 仅输出 2 行 2 列的数据(模拟 2×2 PinMAP + A1
data = {
'A1': 'QFP-8',
'A2': '1', 'B2': 'Pin1',
'A3': '2', 'B3': 'Pin2',
}
writer = StyledXLSXWriter(style=style)
sheet_xml = writer._sheet_xml(data)
# dim 应该反映实际数据范围A1:B3而非模板的 100 列
assert 'dimension ref="A1:B3"' in sheet_xml, \
f"dim 应由实际数据决定,不应包含模板的 100 列\n{sheet_xml[:500]}"
# 不应出现 row r="201"(模板的第 200 行)
assert 'row r="201"' not in sheet_xml, "不应包含模板的多余行"
print("✓ test_f011_output_dims_determined_by_pins passed")
def test_f011_template_borders_in_styles_xml():
"""F011: 有模板时 _styles_xml() 使用模板的边框样式(而非硬编码 thin"""
from template_reader import TemplateStyle, BorderStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [FontStyle(name="Calibri", size=11.0)]
style.borders = [
BorderStyle(top="none", bottom="none", left="none", right="none"),
BorderStyle(top="medium", bottom="medium", left="medium", right="medium"),
]
style.fills = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '1', 'xfId': '0',
'applyBorder': '1'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
# 模板的 medium 边框应该存在(不仅仅是 thin
assert 'medium' in xml, f"模板 medium 边框应出现在 styles.xml 中\n{xml[:800]}"
print("✓ test_f011_template_borders_in_styles_xml passed")
def test_f011_template_fills_in_styles_xml():
"""F011: 有模板时 _styles_xml() 使用模板的填充色。"""
from template_reader import TemplateStyle, FillStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [FontStyle(name="Calibri", size=11.0)]
style.borders = []
style.fills = [
FillStyle(pattern_type="none", fg_color=""),
FillStyle(pattern_type="solid", fg_color="FFFF00"), # 黄色
]
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
{'numFmtId': '0', 'fontId': '0', 'fillId': '1', 'borderId': '0', 'xfId': '0',
'applyFill': '1'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
assert 'FFFF00' in xml, f"模板黄色填充应出现在 styles.xml 中\n{xml[:800]}"
print("✓ test_f011_template_fills_in_styles_xml passed")
def test_template_empty_fonts_fallback():
"""边界测试:空 fonts 回退到默认字体。"""
from template_reader import TemplateStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [] # 空 fonts
style.fills = []
style.borders = []
style.cell_xfs = []
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
# 应回退到默认样式Calibri 11pt
assert 'Calibri' in xml, "空 fonts 应回退到默认 Calibri"
assert 'thin' in xml, "空 borders 应回退到默认 thin"
assert 'cellXfs count="4"' in xml, "应有 4 个 xf"
print("✓ test_template_empty_fonts_fallback passed")
def test_template_color_prefix_auto_fix():
"""边界测试FF 前缀补全。"""
from template_reader import TemplateStyle, FontStyle, FillStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
# color 缺少 FF 前缀
style.fonts = [FontStyle(name="Calibri", size=11.0, color="000000")]
style.fills = [
FillStyle(pattern_type="none"),
FillStyle(pattern_type="solid", fg_color="FFFF00"), # 已有 FF
]
style.borders = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
# 即使原始 color 是 "000000",输出也应是 "FF000000"
assert 'FF000000' in xml, f"color 应自动补全 FF 前缀\n{xml[:800]}"
print("✓ test_template_color_prefix_auto_fix passed")
def test_template_no_styles_xml():
"""边界测试:缺失 styles.xml 时优雅降级。"""
from template_reader import read_template_styles
import tempfile, os
import zipfile
tmpdir = tempfile.mkdtemp()
try:
bad_path = os.path.join(tmpdir, "no_styles.xlsx")
with zipfile.ZipFile(bad_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/></Types>')
zf.writestr('xl/worksheets/sheet1.xml', '<?xml version="1.0"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData/></worksheet>')
style = read_template_styles(bad_path)
assert style is not None, "缺失 styles.xml 不应导致 read_template_styles 返回 None"
assert len(style.fonts) == 0, "无 styles.xmlfont 列表应为空"
assert len(style.fills) == 0, "无 styles.xmlfill 列表应为空"
print("✓ test_template_no_styles_xml passed")
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
if __name__ == "__main__": if __name__ == "__main__":
test_4x4_parse() test_4x4_parse()
test_4x4_validate() test_4x4_validate()
@@ -224,4 +593,15 @@ if __name__ == "__main__":
test_empty_cells() test_empty_cells()
test_no_pins() test_no_pins()
test_12pin_square() test_12pin_square()
test_f012_pinname_position()
# v1.5 新增测试
test_template_path_generation()
test_f011_default_styles_xml()
test_f011_template_fonts_in_styles_xml()
test_f011_output_dims_determined_by_pins()
test_f011_template_borders_in_styles_xml()
test_f011_template_fills_in_styles_xml()
test_template_empty_fonts_fallback()
test_template_color_prefix_auto_fix()
test_template_no_styles_xml()
print("\n✅ All tests passed!") print("\n✅ All tests passed!")

View File

@@ -115,7 +115,7 @@ def validate_pinlist_for_map(
1. **Continuity** — pin numbers must start from 1 with no gaps. 1. **Continuity** — pin numbers must start from 1 with no gaps.
2. **Uniqueness** — no duplicate pin numbers. 2. **Uniqueness** — no duplicate pin numbers.
3. **Perimeter match** — total pin count must equal 3. **Perimeter match** — total pin count must equal
2×rows + 2×cols 4 (the grid perimeter). (rows + cols) × 2 (the grid perimeter).
4. **Non-multiple-of-4** — if pin count is not a multiple of 4, 4. **Non-multiple-of-4** — if pin count is not a multiple of 4,
a warning is issued (but conversion is not blocked). a warning is issued (but conversion is not blocked).
@@ -158,7 +158,8 @@ def validate_pinlist_for_map(
)) ))
# ── 3. Perimeter match ─────────────────────────────────────── # ── 3. Perimeter match ───────────────────────────────────────
expected_total = 2 * rows + 2 * cols - 4 # 周长公式:(rows + cols) * 2
expected_total = (rows + cols) * 2
actual_total = len(entries) actual_total = len(entries)
if actual_total != expected_total: if actual_total != expected_total:
result.errors.append(ValidationError( result.errors.append(ValidationError(

View File

@@ -258,93 +258,35 @@ class StyledXLSXWriter:
return '\n'.join(parts) return '\n'.join(parts)
def _styles_xml(self) -> str: def _styles_xml(self) -> str:
"""Build xl/styles.xml with fonts, fills, borders, and cellXfs.""" """Build xl/styles.xml with fonts, fills, borders, and cellXfs.
F011: 有模板时从模板的 fonts / fills / borders / cellXfs 中
提取实际样式定义;无模板时回退到硬编码默认样式。
保留现有 4 个样式槽位xf 0~3
xf 0 = default (no style)
xf 1 = centered + thin border (pin cells)
xf 2 = bold + centered (A1 package info)
xf 3 = centered + border + light fill (header-like)
有模板时,四个 xf 的字体/边框/填充/对齐从模板读取。
"""
s = self._style s = self._style
# ── Fonts ──────────────────────────────────────────────────
fonts_xml = '<fonts count="2">'
# Font 0: default (no bold)
font_name = "Calibri"
font_size = "11"
font_color = "FF000000"
if s and s.fonts: if s and s.fonts:
f0 = s.fonts[0] # ── 有模板:从模板提取实际值构建样式 ──────────────────
font_name = f0.name fonts_xml = self._build_fonts_xml_from_template(s.fonts)
font_size = str(f0.size) fills_xml = self._build_fills_xml_from_template(s.fills)
font_color = "FF" + f0.color if f0.color and not f0.color.startswith("FF") else f0.color borders_xml = self._build_borders_xml_from_template(s.borders)
fonts_xml += ( cell_xfs_xml = self._build_cell_xfs_xml_from_template(
f'<font><sz val="{font_size}"/>' s.cell_xfs, s.fonts, s.fills, s.borders
f'<name val="{font_name}"/>' )
f'<color rgb="{font_color}"/>' else:
f'</font>' # ── 无模板回退到硬编码默认样式F011 fallback──────
) fonts_xml = self._default_fonts_xml()
# Font 1: bold (for package info in A1) fills_xml = self._default_fills_xml()
fonts_xml += ( borders_xml = self._default_borders_xml()
f'<font><sz val="{font_size}"/>' cell_xfs_xml = self._default_cell_xfs_xml()
f'<b/>'
f'<name val="{font_name}"/>'
f'<color rgb="{font_color}"/>'
f'</font>'
)
fonts_xml += '</fonts>'
# ── Fills ──────────────────────────────────────────────────
fills_xml = '<fills count="2">'
fills_xml += '<fill><patternFill patternType="none"/></fill>'
# Fill 1: light gray for header-like cells
fills_xml += (
'<fill><patternFill patternType="solid">'
'<fgColor rgb="FFF0F0F0"/>'
'</patternFill></fill>'
)
fills_xml += '</fills>'
# ── Borders ────────────────────────────────────────────────
borders_xml = '<borders count="2">'
# Border 0: none
borders_xml += (
'<border>'
'<left/><right/><top/><bottom/><diagonal/>'
'</border>'
)
# Border 1: thin all sides
borders_xml += (
'<border>'
'<left style="thin"/><right style="thin"/>'
'<top style="thin"/><bottom style="thin"/>'
'<diagonal/>'
'</border>'
)
borders_xml += '</borders>'
# ── Cell XFs ───────────────────────────────────────────────
# xf 0: default (no style)
# xf 1: centered with thin border (for pin cells)
# xf 2: bold + centered (for A1 package info)
# xf 3: centered + border + light fill (for header-like)
cell_xfs_xml = '<cellXfs count="4">'
cell_xfs_xml += (
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
)
cell_xfs_xml += (
'<xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0" '
'applyBorder="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
)
cell_xfs_xml += (
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" '
'applyFont="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
)
cell_xfs_xml += (
'<xf numFmtId="0" fontId="0" fillId="1" borderId="1" xfId="0" '
'applyFill="1" applyBorder="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
)
cell_xfs_xml += '</cellXfs>'
parts = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'] parts = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
parts.append( parts.append(
@@ -357,6 +299,250 @@ class StyledXLSXWriter:
parts.append('</styleSheet>') parts.append('</styleSheet>')
return '\n'.join(parts) return '\n'.join(parts)
# ── 模板样式构建F011─────────────────────────────────────────
@staticmethod
def _build_fonts_xml_from_template(fonts: list) -> str:
"""从模板字体列表构建 <fonts> XML。"""
parts = [f'<fonts count="{len(fonts)}">']
for f in fonts:
parts.append('<font>')
parts.append(f'<sz val="{f.size}"/>')
if f.bold:
parts.append('<b/>')
if f.italic:
parts.append('<i/>')
parts.append(f'<name val="{f.name}"/>')
color_val = f.color
if color_val and not color_val.startswith('FF'):
color_val = 'FF' + color_val
if color_val:
parts.append(f'<color rgb="{color_val}"/>')
parts.append('</font>')
parts.append('</fonts>')
return ''.join(parts)
@staticmethod
def _build_fills_xml_from_template(fills: list) -> str:
"""从模板填充列表构建 <fills> XML。"""
parts = [f'<fills count="{len(fills)}">']
for fl in fills:
parts.append('<fill>')
parts.append(f'<patternFill patternType="{fl.pattern_type}">')
if fl.fg_color:
color_val = fl.fg_color
if not color_val.startswith('FF'):
color_val = 'FF' + color_val
parts.append(f'<fgColor rgb="{color_val}"/>')
parts.append('</patternFill>')
parts.append('</fill>')
parts.append('</fills>')
return ''.join(parts)
@staticmethod
def _build_borders_xml_from_template(borders: list) -> str:
"""从模板边框列表构建 <borders> XML。"""
parts = [f'<borders count="{len(borders)}">']
for b in borders:
parts.append('<border>')
for side_name in ('left', 'right', 'top', 'bottom'):
style_val = getattr(b, side_name, 'none')
if style_val and style_val != 'none':
parts.append(f'<{side_name} style="{style_val}"/>')
else:
parts.append(f'<{side_name}/>')
parts.append('<diagonal/>')
parts.append('</border>')
parts.append('</borders>')
return ''.join(parts)
@staticmethod
def _build_cell_xfs_xml_from_template(
cell_xfs: list, fonts: list, fills: list, borders: list
) -> str:
"""从模板 cellXfs 列表构建 <cellXfs> XML。
保留 4 个样式槽位xf 0~3每个从模板对应位置的 xf 提取:
- xf 0: default (模板的 xf 0)
- xf 1: pin cells — 使用模板中带边框的 xf优先否则 fallback
- xf 2: A1 bold — 使用模板中带 bold 字体的 xf优先否则 fallback
- xf 3: header — 使用模板中带填充的 xf优先否则 fallback
"""
def _xf_to_attrs(xf: dict) -> str:
"""将 xf 信息字典转换为属性字符串。"""
attrs = [
f'numFmtId="{xf.get("numFmtId", "0")}"',
f'fontId="{xf.get("fontId", "0")}"',
f'fillId="{xf.get("fillId", "0")}"',
f'borderId="{xf.get("borderId", "0")}"',
f'xfId="{xf.get("xfId", "0")}"',
]
for attr_key in ('applyFont', 'applyFill', 'applyBorder', 'applyAlignment',
'applyNumberFormat', 'applyProtection'):
val = xf.get(attr_key, '')
if val:
attrs.append(f'{attr_key}="{val}"')
return ' '.join(attrs)
def _xf_to_alignment(xf: dict) -> str:
"""从 xf 信息字典生成对齐 XML片段。"""
h_align = xf.get('hAlign', '')
v_align = xf.get('vAlign', '')
wrap_text = xf.get('wrapText', '')
if h_align or v_align or wrap_text:
align_attrs = []
if h_align:
align_attrs.append(f'horizontal="{h_align}"')
if v_align:
align_attrs.append(f'vertical="{v_align}"')
if wrap_text:
align_attrs.append(f'wrapText="{wrap_text}"')
return f'<alignment {" ".join(align_attrs)}/>'
return ''
# 确保有足够的模板样式元素
def _get_or_default(lst, idx, default_name):
if idx < len(lst):
return idx
return 0
# ── 确定 4 个 xf 的映射 ──────────────────────────────────
# xf 0: 模板的 xf 0default
xf0 = cell_xfs[0] if len(cell_xfs) > 0 else {}
fi0 = _get_or_default(fonts, int(xf0.get('fontId', '0')), 'font')
fl0 = _get_or_default(fills, int(xf0.get('fillId', '0')), 'fill')
bd0 = _get_or_default(borders, int(xf0.get('borderId', '0')), 'border')
# xf 1: 寻找模板中有边框的 xfborderId > 0 或 applyBorder 非空)
xf1 = xf0
fi1, fl1, bd1 = fi0, fl0, bd0
for xf in cell_xfs:
bid = int(xf.get('borderId', '0'))
ab = xf.get('applyBorder', '')
if bid > 0 or ab:
xf1 = xf
fi1 = _get_or_default(fonts, int(xf1.get('fontId', '0')), 'font')
fl1 = _get_or_default(fills, int(xf1.get('fillId', '0')), 'fill')
bd1 = _get_or_default(borders, int(xf1.get('borderId', '0')), 'border')
break
# xf 2: 寻找模板中有 bold 字体的 xf
xf2 = xf0
fi2, fl2, bd2 = fi0, fl0, bd0
bold_font_id = None
for i, f in enumerate(fonts):
if f.bold:
bold_font_id = i
break
if bold_font_id is not None:
for xf in cell_xfs:
fid = int(xf.get('fontId', '0'))
if xf.get('applyFont', '') or fid == bold_font_id:
xf2 = xf
break
fi2 = bold_font_id
fl2 = _get_or_default(fills, int(xf2.get('fillId', '0')), 'fill')
bd2 = _get_or_default(borders, int(xf2.get('borderId', '0')), 'border')
# xf 3: 寻找模板中有填充的 xffillId > 0 或 applyFill 非空)
xf3 = xf0
fi3, fl3, bd3 = fi0, fl0, bd0
for xf in cell_xfs:
fid = int(xf.get('fillId', '0'))
af = xf.get('applyFill', '')
if fid > 0 or af:
xf3 = xf
fi3 = _get_or_default(fonts, int(xf3.get('fontId', '0')), 'font')
fl3 = _get_or_default(fills, int(xf3.get('fillId', '0')), 'fill')
bd3 = _get_or_default(borders, int(xf3.get('borderId', '0')), 'border')
break
# ── 构建 4 个 xf ──────────────────────────────────────────
parts = ['<cellXfs count="4">']
# xf 0: default (no style)
parts.append(f'<xf numFmtId="0" fontId="{fi0}" fillId="{fl0}" borderId="{bd0}" xfId="0"/>')
# xf 1: pin cells (border + center align)
align1 = _xf_to_alignment(xf1)
parts.append(
f'<xf numFmtId="0" fontId="{fi1}" fillId="{fl1}" borderId="{bd1}" xfId="0" applyBorder="1">'
f'{align1}'
f'</xf>'
)
# xf 2: A1 package info (bold + center align)
align2 = _xf_to_alignment(xf2) or '<alignment horizontal="center" vertical="center"/>'
parts.append(
f'<xf numFmtId="0" fontId="{fi2}" fillId="{fl2}" borderId="{bd2}" xfId="0" applyFont="1">'
f'{align2}'
f'</xf>'
)
# xf 3: header-like (fill + border + center align)
align3 = _xf_to_alignment(xf3) or '<alignment horizontal="center" vertical="center"/>'
parts.append(
f'<xf numFmtId="0" fontId="{fi3}" fillId="{fl3}" borderId="{bd3}" xfId="0" applyFill="1" applyBorder="1">'
f'{align3}'
f'</xf>'
)
parts.append('</cellXfs>')
return ''.join(parts)
# ── 默认硬编码样式(无模板时回退)────────────────────────────
@staticmethod
def _default_fonts_xml() -> str:
"""生成默认硬编码字体font 0 = Calibri 11pt, font 1 = Calibri 11pt bold。"""
return (
'<fonts count="2">'
'<font><sz val="11"/><name val="Calibri"/><color rgb="FF000000"/></font>'
'<font><sz val="11"/><b/><name val="Calibri"/><color rgb="FF000000"/></font>'
'</fonts>'
)
@staticmethod
def _default_fills_xml() -> str:
"""生成默认硬编码填充fill 0 = none, fill 1 = light gray。"""
return (
'<fills count="2">'
'<fill><patternFill patternType="none"/></fill>'
'<fill><patternFill patternType="solid"><fgColor rgb="FFF0F0F0"/></patternFill></fill>'
'</fills>'
)
@staticmethod
def _default_borders_xml() -> str:
"""生成默认硬编码边框border 0 = none, border 1 = thin all sides。"""
return (
'<borders count="2">'
'<border><left/><right/><top/><bottom/><diagonal/></border>'
'<border><left style="thin"/><right style="thin"/>'
'<top style="thin"/><bottom style="thin"/><diagonal/></border>'
'</borders>'
)
@staticmethod
def _default_cell_xfs_xml() -> str:
"""生成默认硬编码 cellXfs
xf 0 = default, xf 1 = centered+border, xf 2 = bold+centered, xf 3 = centered+border+fill。
"""
return (
'<cellXfs count="4">'
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
'<xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0" applyBorder="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
'<xf numFmtId="0" fontId="0" fillId="1" borderId="1" xfId="0" applyFill="1" applyBorder="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
'</cellXfs>'
)
def _sheet_xml(self, data: dict[str, str]) -> str: def _sheet_xml(self, data: dict[str, str]) -> str:
"""Build sheet1.xml with style indices applied.""" """Build sheet1.xml with style indices applied."""
max_row = 0 max_row = 0

View File

@@ -9,6 +9,9 @@
- ✅ GUI 文件选择 + 命令行双模式 - ✅ GUI 文件选择 + 命令行双模式
- ✅ 智能结构验证(重复/间隙/空单元格检测) - ✅ 智能结构验证(重复/间隙/空单元格检测)
- ✅ 逆时针 PinMAP → 顺时针 PinList 自动转换 - ✅ 逆时针 PinMAP → 顺时针 PinList 自动转换
- ✅ 双向转换MAP→List 与 List→MAP
-**独立模板**MAP→List 使用 `BallList-Template.xlsx`List→MAP 使用 `BallMAP-Template.xlsx`
-**模板格式提取**:从模板读取字体、边框、填充、对齐、列宽、行高并应用到输出
## 快速开始 ## 快速开始
@@ -30,9 +33,11 @@ pinmap-to-pinlist/
│ ├── src/ # 源代码 │ ├── src/ # 源代码
│ └── docs/ # 架构文档 │ └── docs/ # 架构文档
├── Test/ ├── Test/
│ ├── fixtures/ # 测试夹具 │ ├── fixtures/ # 测试夹具(含模板文件)
│ └── test_report.md # 测试报告 │ └── test_report.md # 测试报告
├── Releases/ # 发布包 ├── Releases/ # 发布包
├── BallList-Template.xlsx # MAP→List 样式模板(可放置于项目根目录)
├── BallMAP-Template.xlsx # List→MAP 样式模板(可放置于项目根目录)
├── CHANGELOG.md ├── CHANGELOG.md
└── README.md └── README.md
``` ```

Binary file not shown.

Binary file not shown.

BIN
Test/fixtures/BallList-Template.xlsx vendored Normal file

Binary file not shown.

BIN
Test/fixtures/BallMAP-Template.xlsx vendored Normal file

Binary file not shown.

Binary file not shown.

1
Test/fixtures/template_corrupt.xlsx vendored Normal file
View File

@@ -0,0 +1 @@
This is not a valid xlsx file. It's just plain text pretending to be xlsx.

BIN
Test/fixtures/template_minimal.xlsx vendored Normal file

Binary file not shown.

BIN
Test/fixtures/template_narrow.xlsx vendored Normal file

Binary file not shown.

View File

@@ -175,16 +175,13 @@ def test_list_to_map(r: TestRunner):
tmpdir = tempfile.mkdtemp(prefix="pinmap_test_") tmpdir = tempfile.mkdtemp(prefix="pinmap_test_")
try: try:
# ── TC-LM-001: 4×4 PinList → 2×2 PinMAP (16引脚) ── # ── TC-LM-001: 5×5 PinList → PinMAP (20引脚) ──
# 2×rows + 2×cols - 4 = 2*4 + 2*4 - 4 = 12, not 16 # v1.3: (r+c)*2 = (5+5)*2 = 20 pins
# Actually: for a 4×4 grid: 2*4 + 2*4 - 4 = 12 pins
# For 16 pins on a square: 2r + 2c - 4 = 16 → r=c=5 → 5×5 grid
# Let's use 5×5 grid for 16 pins
def _tc_lm_001(result): def _tc_lm_001(result):
# 5×5 grid → 2*5 + 2*5 - 4 = 16 pins # 5×5 grid → (5+5)*2 = 20 pins
data = {'A1': 'QFP-16'} data = {'A1': 'QFP-20'}
for i in range(1, 17): for i in range(1, 21):
row = i + 1 # row 2..17 row = i + 1 # row 2..21
data[f'A{row}'] = f'Pin{i}' data[f'A{row}'] = f'Pin{i}'
data[f'B{row}'] = str(i) data[f'B{row}'] = str(i)
filepath = os.path.join(tmpdir, 'test_5x5_pinlist.xlsx') filepath = os.path.join(tmpdir, 'test_5x5_pinlist.xlsx')
@@ -192,8 +189,8 @@ def test_list_to_map(r: TestRunner):
# Parse # Parse
pkg, entries = parse_pinlist(filepath) pkg, entries = parse_pinlist(filepath)
assert pkg == 'QFP-16', f"封装信息应为QFP-16, 实际: {pkg}" assert pkg == 'QFP-20', f"封装信息应为QFP-20, 实际: {pkg}"
assert len(entries) == 16, f"应有16个引脚, 实际: {len(entries)}" assert len(entries) == 20, f"应有20个引脚, 实际: {len(entries)}"
# Validate with 5×5 grid # Validate with 5×5 grid
validation = validate_pinlist(entries, 5, 5) validation = validate_pinlist(entries, 5, 5)
@@ -203,35 +200,32 @@ def test_list_to_map(r: TestRunner):
# Generate PinMAP # Generate PinMAP
output = generate_pinmap(entries, 5, 5, pkg, output_path=None) output = generate_pinmap(entries, 5, 5, pkg, output_path=None)
assert 'A1' in output, "A1应有封装信息" assert 'A1' in output, "A1应有封装信息"
assert output['A1'] == 'QFP-16' assert output['A1'] == 'QFP-20'
result.ok(f"解析成功, 封装={pkg}, Pin数={len(entries)}, 5×5布局验证通过") result.ok(f"解析成功, 封装={pkg}, Pin数={len(entries)}, 5×5布局验证通过")
r.run("TC-LM-001: 5×5 PinList→PinMAP (16引脚)", _tc_lm_001) r.run("TC-LM-001: 5×5 PinList→PinMAP (20引脚)", _tc_lm_001)
# ── TC-LM-002: 长方形 PinList → 4×8 PinMAP (32引脚) ── # ── TC-LM-002: 长方形 PinList → 6×10 PinMAP (32引脚) ──
# 2*4 + 2*8 - 4 = 8 + 16 - 4 = 20, not 32 # v1.3: (r+c)*2 = (6+10)*2 = 32 pins
# For 32 pins: 2r + 2c - 4 = 32
# Try 4×16: 2*4 + 2*16 - 4 = 8 + 32 - 4 = 36, no
# Try 6×12: 2*6 + 2*12 - 4 = 12 + 24 - 4 = 32 ✓
def _tc_lm_002(result): def _tc_lm_002(result):
data = {'A1': 'LQFP-32'} data = {'A1': 'LQFP-32'}
for i in range(1, 33): for i in range(1, 33):
row = i + 1 row = i + 1
data[f'A{row}'] = f'PIN_{i:02d}' data[f'A{row}'] = f'PIN_{i:02d}'
data[f'B{row}'] = str(i) data[f'B{row}'] = str(i)
filepath = os.path.join(tmpdir, 'test_6x12_pinlist.xlsx') filepath = os.path.join(tmpdir, 'test_6x10_pinlist.xlsx')
create_pinlist_xlsx(data, filepath) create_pinlist_xlsx(data, filepath)
pkg, entries = parse_pinlist(filepath) pkg, entries = parse_pinlist(filepath)
assert len(entries) == 32, f"应有32个引脚, 实际: {len(entries)}" assert len(entries) == 32, f"应有32个引脚, 实际: {len(entries)}"
validation = validate_pinlist(entries, 6, 12) validation = validate_pinlist(entries, 6, 10)
assert validation.is_valid, f"验证应通过: {validation.errors}" assert validation.is_valid, f"验证应通过: {validation.errors}"
# Generate and write to file # Generate and write to file
outpath = os.path.join(tmpdir, 'test_6x12_pinmap.xlsx') outpath = os.path.join(tmpdir, 'test_6x10_pinmap.xlsx')
output = generate_pinmap(entries, 6, 12, pkg, output_path=outpath) output = generate_pinmap(entries, 6, 10, pkg, output_path=outpath)
assert os.path.exists(outpath), "输出文件应存在" assert os.path.exists(outpath), "输出文件应存在"
# Verify output can be read back # Verify output can be read back
@@ -239,16 +233,16 @@ def test_list_to_map(r: TestRunner):
assert (0, 0) in out_cells, "A1应有数据" assert (0, 0) in out_cells, "A1应有数据"
assert out_cells[(0, 0)] == 'LQFP-32', f"A1应为LQFP-32, 实际: {out_cells.get((0,0))}" assert out_cells[(0, 0)] == 'LQFP-32', f"A1应为LQFP-32, 实际: {out_cells.get((0,0))}"
result.ok(f"解析成功, 封装={pkg}, Pin数={len(entries)}, 6×12布局+文件输出验证通过") result.ok(f"解析成功, 封装={pkg}, Pin数={len(entries)}, 6×10布局+文件输出验证通过")
r.run("TC-LM-002: 6×12 PinList→PinMAP (32引脚)", _tc_lm_002) r.run("TC-LM-002: 6×10 PinList→PinMAP (32引脚)", _tc_lm_002)
# ── TC-LM-003: 带模板文件的转换 ── # ── TC-LM-003: 带模板文件的转换 ──
# 先用一个正常PinMAP作为模板 # v1.3: 5×5 grid → (5+5)*2 = 20 pins
def _tc_lm_003(result): def _tc_lm_003(result):
# 创建模板 PinMAP # 创建模板 PinMAP
template_data = {'A1': 'QFP-16'} template_data = {'A1': 'QFP-20'}
for i in range(1, 17): for i in range(1, 21):
row = i + 1 row = i + 1
template_data[f'A{row}'] = f'Pin{i}' template_data[f'A{row}'] = f'Pin{i}'
template_data[f'B{row}'] = str(i) template_data[f'B{row}'] = str(i)
@@ -258,8 +252,8 @@ def test_list_to_map(r: TestRunner):
write_xlsx_with_style(template_data, template_path) write_xlsx_with_style(template_data, template_path)
# 创建 PinList 并写入模板同目录 # 创建 PinList 并写入模板同目录
data = {'A1': 'QFP-16'} data = {'A1': 'QFP-20'}
for i in range(1, 17): for i in range(1, 21):
row = i + 1 row = i + 1
data[f'A{row}'] = f'Pin{i}' data[f'A{row}'] = f'Pin{i}'
data[f'B{row}'] = str(i) data[f'B{row}'] = str(i)
@@ -329,10 +323,9 @@ def test_list_to_map(r: TestRunner):
r.run("TC-LM-005: Pin序号重复", _tc_lm_005) r.run("TC-LM-005: Pin序号重复", _tc_lm_005)
# ── TC-LM-006: Pin 总数不匹配 ── # ── TC-LM-006: Pin 总数不匹配 ──
# v1.3: 3×4 grid → (3+4)*2 = 14 pins
def _tc_lm_006(result): def _tc_lm_006(result):
# 创建8个引脚的PinList指定3×3网格(需要8个引脚) # 创建8个引脚的PinList但3×4网格需要14个引脚
# 2*3 + 2*3 - 4 = 8 → 匹配!
# 改用3×4网格(需要2*3+2*4-4=10个引脚)
data = {'A1': 'QFP-test'} data = {'A1': 'QFP-test'}
for i in range(1, 9): # 8 pins for i in range(1, 9): # 8 pins
row = i + 1 row = i + 1
@@ -342,7 +335,7 @@ def test_list_to_map(r: TestRunner):
create_pinlist_xlsx(data, filepath) create_pinlist_xlsx(data, filepath)
pkg, entries = parse_pinlist(filepath) pkg, entries = parse_pinlist(filepath)
# 3×4 needs 10 pins, but we have 8 # 3×4 needs 14 pins, but we have 8
validation = validate_pinlist(entries, 3, 4) validation = validate_pinlist(entries, 3, 4)
assert not validation.is_valid, "应验证失败" assert not validation.is_valid, "应验证失败"
assert any("不匹配" in e.message for e in validation.errors), \ assert any("不匹配" in e.message for e in validation.errors), \
@@ -352,11 +345,12 @@ def test_list_to_map(r: TestRunner):
r.run("TC-LM-006: Pin总数不匹配", _tc_lm_006) r.run("TC-LM-006: Pin总数不匹配", _tc_lm_006)
# ── TC-LM-007: 缺少 PinName ── # ── TC-LM-007: 缺少 PinName ──
# v1.3: 2×3 grid → (2+3)*2 = 10 pins
def _tc_lm_007(result): def _tc_lm_007(result):
data = {'A1': 'QFP-test'} data = {'A1': 'QFP-test'}
# 6个引脚其中第2个缺PinName # 10个引脚其中第2个缺PinName
# 2×4 grid: 2*2+2*4-4=6 pins ✓ entries_data = [('Pin1', '1'), ('', '2'), ('Pin3', '3'), ('Pin4', '4'), ('Pin5', '5'),
entries_data = [('Pin1', '1'), ('', '2'), ('Pin3', '3'), ('Pin4', '4'), ('Pin5', '5'), ('Pin6', '6')] ('Pin6', '6'), ('Pin7', '7'), ('Pin8', '8'), ('Pin9', '9'), ('Pin10', '10')]
for i, (name, num) in enumerate(entries_data): for i, (name, num) in enumerate(entries_data):
row = i + 2 row = i + 2
data[f'A{row}'] = name data[f'A{row}'] = name
@@ -376,14 +370,12 @@ def test_list_to_map(r: TestRunner):
r.run("TC-LM-007: 缺少PinName (warning)", _tc_lm_007) r.run("TC-LM-007: 缺少PinName (warning)", _tc_lm_007)
# ── TC-LM-008: 非4倍数提示 ── # ── TC-LM-008: 非4倍数提示 ──
# v1.3: (r+c)*2 is always even, but may not be multiple of 4
# (3+4)*2 = 14, 14 % 4 = 2 → not a multiple of 4
def _tc_lm_008(result): def _tc_lm_008(result):
# 6个引脚 → 不是4的倍数 # 14个引脚 → 不是4的倍数
# 2r+2c-4=6 → try 3×4: 2*3+2*4-4=10, no
# try 2×5: 2*2+2*5-4=8, no
# try 4×3: 2*4+2*3-4=10, no
# Actually: 2r+2c-4=6 → r+c=5 → try r=2,c=3: 4+6-4=6 ✓
data = {'A1': 'QFP-test'} data = {'A1': 'QFP-test'}
for i in range(1, 7): for i in range(1, 15):
row = i + 1 row = i + 1
data[f'A{row}'] = f'Pin{i}' data[f'A{row}'] = f'Pin{i}'
data[f'B{row}'] = str(i) data[f'B{row}'] = str(i)
@@ -391,22 +383,17 @@ def test_list_to_map(r: TestRunner):
create_pinlist_xlsx(data, filepath) create_pinlist_xlsx(data, filepath)
pkg, entries = parse_pinlist(filepath) pkg, entries = parse_pinlist(filepath)
validation = validate_pinlist(entries, 2, 3) validation = validate_pinlist(entries, 3, 4)
assert validation.is_valid, f"应验证通过: {validation.errors}" assert validation.is_valid, f"应验证通过: {validation.errors}"
# 6 % 4 != 0, 应有 info 提示 # 14 % 4 != 0, 应有 info 提示
# 注意: validate_pinlist 返回的 ValidationResult 没有 infos 字段
# 但 info 消息是附加到 warnings 中的
# 实际上看代码infos 是单独列表,但 ValidationResult 只有 errors 和 warnings
# 所以 info 不会出现在 validation.warnings 中
# 让我们直接检查 validate_pinlist 的返回
result.ok(f"验证通过, Pin数={len(entries)} (非4倍数)") result.ok(f"验证通过, Pin数={len(entries)} (非4倍数)")
r.run("TC-LM-008: 非4倍数提示", _tc_lm_008) r.run("TC-LM-008: 非4倍数提示", _tc_lm_008)
# ── TC-LM-009: 布局计算正确性 ── # ── TC-LM-009: 布局计算正确性 ──
# v1.3: 3×3 grid → (3+3)*2 = 12 pins
def _tc_lm_009(result): def _tc_lm_009(result):
# 3×3 grid → 2*3 + 2*3 - 4 = 8 pins entries = [PinListEntry(number=i, name=f'P{i}') for i in range(1, 13)]
entries = [PinListEntry(number=i, name=f'P{i}') for i in range(1, 9)]
layout = calculate_layout(entries, 3, 3) layout = calculate_layout(entries, 3, 3)
# 验证四条边都有引脚 # 验证四条边都有引脚
@@ -415,26 +402,25 @@ def test_list_to_map(r: TestRunner):
assert 'right' in layout, "应有right边" assert 'right' in layout, "应有right边"
assert 'top' in layout, "应有top边" assert 'top' in layout, "应有top边"
# 验证引脚数量分配 # 验证引脚数量分配 (v1.3: 每条边独立)
# left: rows=3, bottom: cols-1=2, right: rows-2=1, top: cols-1=2 # left: rows=3, bottom: cols=3, right: rows=3, top: cols=3
assert len(layout['left'].pins) == 3, f"left应有3个引脚, 实际: {len(layout['left'].pins)}" assert len(layout['left'].pins) == 3, f"left应有3个引脚, 实际: {len(layout['left'].pins)}"
assert len(layout['bottom'].pins) == 2, f"bottom应有2个引脚, 实际: {len(layout['bottom'].pins)}" assert len(layout['bottom'].pins) == 3, f"bottom应有3个引脚, 实际: {len(layout['bottom'].pins)}"
assert len(layout['right'].pins) == 1, f"right应有1个引脚, 实际: {len(layout['right'].pins)}" assert len(layout['right'].pins) == 3, f"right应有3个引脚, 实际: {len(layout['right'].pins)}"
assert len(layout['top'].pins) == 2, f"top应有2个引脚, 实际: {len(layout['top'].pins)}" assert len(layout['top'].pins) == 3, f"top应有3个引脚, 实际: {len(layout['top'].pins)}"
# 验证总引脚数 # 验证总引脚数
total = sum(len(e.pins) for e in layout.values()) total = sum(len(e.pins) for e in layout.values())
assert total == 8, f"总引脚数应为8, 实际: {total}" assert total == 12, f"总引脚数应为12, 实际: {total}"
# 验证逆时针顺序: left(1,2,3) → bottom(4,5) → right(6) → top(7,8) # 验证逆时针顺序: left(1,2,3) → bottom(4,5,6) → right(7,8,9) → top(10,11,12)
assert layout['left'].pins[0][0] == 1, "left第一个应为Pin1" assert layout['left'].pins[0][0] == 1, "left第一个应为Pin1"
assert layout['left'].pins[-1][0] == 3, "left最后一个应为Pin3" assert layout['left'].pins[-1][0] == 3, "left最后一个应为Pin3"
assert layout['bottom'].pins[0][0] == 4, "bottom第一个应为Pin4" assert layout['bottom'].pins[0][0] == 4, "bottom第一个应为Pin4"
assert layout['right'].pins[0][0] == 6, "right应为Pin6" assert layout['right'].pins[0][0] == 7, "right应为Pin7"
assert layout['top'].pins[0][0] == 7, "top第一个应为Pin7" assert layout['top'].pins[0][0] == 10, "top第一个应为Pin10"
assert layout['top'].pins[1][0] == 8, "top第二个应为Pin8"
result.ok(f"布局计算正确: left=3, bottom=2, right=1, top=2, 逆时针顺序正确") result.ok(f"布局计算正确: left=3, bottom=3, right=3, top=3, 逆时针顺序正确")
r.run("TC-LM-009: 布局计算正确性", _tc_lm_009) r.run("TC-LM-009: 布局计算正确性", _tc_lm_009)
@@ -470,10 +456,10 @@ def test_list_to_map(r: TestRunner):
r.run("TC-LM-011b: 无效尺寸输入(列数<2)", _tc_lm_011b) r.run("TC-LM-011b: 无效尺寸输入(列数<2)", _tc_lm_011b)
# ── TC-LM-012: 输出文件正确性 ── # ── TC-LM-012: 输出文件正确性 ──
# v1.3: 3×3 grid → (3+3)*2 = 12 pins
def _tc_lm_012(result): def _tc_lm_012(result):
# 创建4×4 PinList (8 pins, 3×3 grid) data = {'A1': 'QFP-12'}
data = {'A1': 'QFP-8'} for i in range(1, 13):
for i in range(1, 9):
row = i + 1 row = i + 1
data[f'A{row}'] = f'Pin{i}' data[f'A{row}'] = f'Pin{i}'
data[f'B{row}'] = str(i) data[f'B{row}'] = str(i)
@@ -486,26 +472,26 @@ def test_list_to_map(r: TestRunner):
# 读取输出并验证 # 读取输出并验证
out_cells = read_xlsx_cells(outpath) out_cells = read_xlsx_cells(outpath)
assert out_cells[(0, 0)] == 'QFP-8', f"A1应为QFP-8, 实际: {out_cells.get((0,0))}" assert out_cells[(0, 0)] == 'QFP-12', f"A1应为QFP-12, 实际: {out_cells.get((0,0))}"
# 验证所有8个引脚序号都在输出中 # 验证所有12个引脚序号都在输出中
found_nums = set() found_nums = set()
for (row, col), val in out_cells.items(): for (row, col), val in out_cells.items():
if val.isdigit() and int(val) >= 1: for part in str(val).split("/"):
found_nums.add(int(val)) if part.isdigit() and int(part) >= 1:
assert found_nums == {1, 2, 3, 4, 5, 6, 7, 8}, \ found_nums.add(int(part))
f"应包含1-8所有序号, 实际: {sorted(found_nums)}" assert found_nums == {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}, \
f"应包含1-12所有序号, 实际: {sorted(found_nums)}"
result.ok(f"输出文件验证通过: A1={out_cells[(0,0)]}, 包含Pin1-Pin8") result.ok(f"输出文件验证通过: A1={out_cells[(0,0)]}, 包含Pin1-Pin12")
r.run("TC-LM-012: 输出文件正确性", _tc_lm_012) r.run("TC-LM-012: 输出文件正确性", _tc_lm_012)
# ── TC-LM-013: 端到端 roundtrip (PinMAP → PinList → PinMAP) ── # ── TC-LM-013: 端到端 roundtrip (PinMAP → PinList → PinMAP) ──
# v1.3: 3×3 grid → (3+3)*2 = 12 pins
def _tc_lm_013(result): def _tc_lm_013(result):
# 创建一个符合周长公式的 PinList → PinMAP → PinList roundtrip data = {'A1': 'QFP-12'}
# 3×3 grid → 2*3+2*3-4=8 pins for i in range(1, 13):
data = {'A1': 'QFP-8'}
for i in range(1, 9):
row = i + 1 row = i + 1
data[f'A{row}'] = f'Pin{i}' data[f'A{row}'] = f'Pin{i}'
data[f'B{row}'] = str(i) data[f'B{row}'] = str(i)
@@ -523,15 +509,15 @@ def test_list_to_map(r: TestRunner):
rt_validation = validate_pinmap(rt_pinmap) rt_validation = validate_pinmap(rt_pinmap)
rt_pinlist = generate_pinlist(rt_pinmap, rt_validation) rt_pinlist = generate_pinlist(rt_pinmap, rt_validation)
assert len(rt_pinlist.rows) == 8, \ assert len(rt_pinlist.rows) == 12, \
f"Roundtrip后应有8个引脚, 实际: {len(rt_pinlist.rows)}" f"Roundtrip后应有12个引脚, 实际: {len(rt_pinlist.rows)}"
# 验证序号一致 # 验证序号一致
orig_nums = [e.number for e in entries] orig_nums = [e.number for e in entries]
rt_nums = [num for _, num in rt_pinlist.rows] rt_nums = [num for _, num in rt_pinlist.rows]
assert orig_nums == rt_nums, f"序号应一致: {orig_nums} vs {rt_nums}" assert orig_nums == rt_nums, f"序号应一致: {orig_nums} vs {rt_nums}"
result.ok(f"Roundtrip成功: PinList(8) → PinMAP(3×3) → PinList({len(rt_pinlist.rows)}), 序号一致") result.ok(f"Roundtrip成功: PinList(12) → PinMAP(3×3) → PinList({len(rt_pinlist.rows)}), 序号一致")
r.run("TC-LM-013: 端到端Roundtrip (MAP→List→MAP)", _tc_lm_013) r.run("TC-LM-013: 端到端Roundtrip (MAP→List→MAP)", _tc_lm_013)
@@ -575,6 +561,342 @@ def test_list_to_map(r: TestRunner):
shutil.rmtree(tmpdir, ignore_errors=True) shutil.rmtree(tmpdir, ignore_errors=True)
# ── Part 3: v1.5 Template / Style Integration Tests ────────────────
def test_v15_styles(r: TestRunner):
"""v1.5: 模板分离与样式应用集成测试方案B — 直接调用底层函数传入 fixture 路径)。"""
fixture_dir = os.path.join(os.path.dirname(__file__), 'fixtures')
tmpdir = tempfile.mkdtemp(prefix="pinmap_v15_")
from xlsx_writer import write_xlsx_with_style
try:
# ── TC-v1.5-001: MAP→List 加载 BallList 模板 ──
def _tc_v15_001(result):
template_path = os.path.join(fixture_dir, 'BallList-Template.xlsx')
assert os.path.exists(template_path), f"BallList 模板文件不存在: {template_path}"
style = read_template_styles(template_path)
assert style is not None, "BallList 模板样式应成功读取"
assert len(style.fonts) > 0, "应有字体定义"
assert len(style.borders) > 0, "应有边框定义"
assert 0 in style.column_widths, "应有列宽定义"
result.ok(f"模板加载成功: fonts={len(style.fonts)}, borders={len(style.borders)}, width_A={style.column_widths.get(0)}")
r.run("TC-v1.5-001: MAP->List 加载 BallList 模板", _tc_v15_001)
# ── TC-v1.5-002: MAP→List 无模板降级 ──
def _tc_v15_002(result):
style = read_template_styles('/nonexistent/nonexistent_template.xlsx')
assert style is None, "不存在的模板应返回 None"
result.ok("无模板文件时优雅返回 None")
r.run("TC-v1.5-002: MAP->List 无模板降级", _tc_v15_002)
# ── TC-v1.5-003: List→MAP 加载 BallMAP 模板 ──
def _tc_v15_003(result):
template_path = os.path.join(fixture_dir, 'BallMAP-Template.xlsx')
assert os.path.exists(template_path), f"BallMAP 模板文件不存在: {template_path}"
style = read_template_styles(template_path)
assert style is not None, "BallMAP 模板样式应成功读取"
assert len(style.fonts) > 0, "应有字体定义"
assert len(style.borders) > 0, "应有边框定义"
assert 0 in style.row_heights, "应有行高定义"
result.ok(f"模板加载成功: fonts={len(style.fonts)}, borders={len(style.borders)}, row_height={style.row_heights.get(0)}")
r.run("TC-v1.5-003: List->MAP 加载 BallMAP 模板", _tc_v15_003)
# ── TC-v1.5-004: List→MAP 无模板降级 ──
def _tc_v15_004(result):
style = read_template_styles('/nonexistent/nonexistent_template.xlsx')
assert style is None, "不存在的模板应返回 None"
result.ok("无模板文件时优雅返回 None")
r.run("TC-v1.5-004: List->MAP 无模板降级", _tc_v15_004)
# ── TC-v1.5-005: 两个方向独立使用各自模板 ──
def _tc_v15_005(result):
bl_path = os.path.join(fixture_dir, 'BallList-Template.xlsx')
bm_path = os.path.join(fixture_dir, 'BallMAP-Template.xlsx')
style_bl = read_template_styles(bl_path)
style_bm = read_template_styles(bm_path)
assert style_bl is not None, "BallList 模板应成功加载"
assert style_bm is not None, "BallMAP 模板应成功加载"
# BallList 有列宽BallMAP 有行高
assert 0 in style_bl.column_widths, "BallList 应有列宽"
assert 0 in style_bm.row_heights, "BallMAP 应有行高"
result.ok(f"两个模板独立: BL fonts={len(style_bl.fonts)}, BM fonts={len(style_bm.fonts)}")
r.run("TC-v1.5-005: 两个方向独立使用各自模板", _tc_v15_005)
# ── TC-v1.5-006: 模板损坏优雅降级 ──
def _tc_v15_006(result):
corrupt_path = os.path.join(fixture_dir, 'template_corrupt.xlsx')
assert os.path.exists(corrupt_path), f"损坏模板文件不存在: {corrupt_path}"
style = read_template_styles(corrupt_path)
assert style is None, "损坏模板应返回 None优雅降级"
result.ok("损坏模板优雅返回 None")
r.run("TC-v1.5-006: 模板损坏优雅降级", _tc_v15_006)
# ── TC-v1.5-007: 模板字体应用到输出文件 ──
def _tc_v15_007(result):
template_path = os.path.join(fixture_dir, 'BallMAP-Template.xlsx')
style = read_template_styles(template_path)
assert style is not None, "模板样式应成功读取"
# 用模板生成 3x3 PinMAP
entries = [PinListEntry(number=i+1, name=f"PIN{i+1:02d}") for i in range(12)]
outpath = os.path.join(tmpdir, 'v15_007_output.xlsx')
generate_pinmap(entries, 3, 3, "QFP-12", template_style=style, output_path=outpath)
# 验证输出 styles.xml 包含模板字体
import zipfile
with zipfile.ZipFile(outpath, 'r') as zf:
assert 'xl/styles.xml' in zf.namelist(), "输出应包含 styles.xml"
styles_xml = zf.read('xl/styles.xml').decode('utf-8')
assert '宋体' in styles_xml, f"输出 styles.xml 应包含宋体"
assert '14' in styles_xml, f"输出 styles.xml 应包含字号 14"
result.ok("输出 styles.xml 包含模板字体(宋体 14pt)")
r.run("TC-v1.5-007: 模板字体应用到输出文件", _tc_v15_007)
# ── TC-v1.5-008: 模板列宽应用到输出文件 ──
def _tc_v15_008(result):
template_path = os.path.join(fixture_dir, 'BallList-Template.xlsx')
style = read_template_styles(template_path)
assert style is not None, "模板样式应成功读取"
# 用模板生成 MAP->List 输出
data = {'A1': 'QFP-44', 'A2': 'Pin1', 'B2': '1', 'A3': 'Pin2', 'B3': '2'}
outpath = os.path.join(tmpdir, 'v15_008_output.xlsx')
write_xlsx_with_style(data, outpath, style)
# 验证列宽
import zipfile
import xml.etree.ElementTree as ET
_S = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
with zipfile.ZipFile(outpath, 'r') as zf:
sheet_xml = zf.read('xl/worksheets/sheet1.xml')
root = ET.fromstring(sheet_xml)
cols_elem = root.find(f'{{{_S}}}cols')
assert cols_elem is not None, "输出 sheet1.xml 应包含 cols"
cols = cols_elem.findall(f'{{{_S}}}col')
assert len(cols) >= 2, f"应有至少2列宽度定义实际: {len(cols)}"
width_a = float(cols[0].get('width', '0'))
width_b = float(cols[1].get('width', '0'))
assert abs(width_a - 25.0) < 0.5, f"A列宽 ({width_a}) 应与模板 25 接近"
assert abs(width_b - 18.0) < 0.5, f"B列宽 ({width_b}) 应与模板 18 接近"
result.ok(f"列宽验证通过: A={width_a:.1f}, B={width_b:.1f}")
r.run("TC-v1.5-008: 模板列宽应用到输出文件", _tc_v15_008)
# ── TC-v1.5-009: 模板行高应用到输出文件 ──
def _tc_v15_009(result):
template_path = os.path.join(fixture_dir, 'BallMAP-Template.xlsx')
style = read_template_styles(template_path)
assert style is not None, "模板样式应成功读取"
entries = [PinListEntry(number=i+1, name=f"PIN{i+1:02d}") for i in range(12)]
outpath = os.path.join(tmpdir, 'v15_009_output.xlsx')
generate_pinmap(entries, 3, 3, "QFP-12", template_style=style, output_path=outpath)
import zipfile
import xml.etree.ElementTree as ET
_S = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
with zipfile.ZipFile(outpath, 'r') as zf:
sheet_xml = zf.read('xl/worksheets/sheet1.xml')
root = ET.fromstring(sheet_xml)
sheet_data = root.find(f'{{{_S}}}sheetData')
rows = sheet_data.findall(f'{{{_S}}}row')
found_ht = False
for row in rows:
ht = row.get('ht')
if ht:
found_ht = True
ht_val = float(ht)
assert abs(ht_val - 25.0) < 0.5, f"行高 ({ht_val}) 应与模板 25 接近"
break
assert found_ht, "至少应有一个 row 包含 ht 属性"
result.ok("行高验证通过: ht=25")
r.run("TC-v1.5-009: 模板行高应用到输出文件", _tc_v15_009)
# ── TC-v1.5-010: 两个方向使用不同模板各自的格式 ──
def _tc_v15_010(result):
bl_path = os.path.join(fixture_dir, 'BallList-Template.xlsx')
bm_path = os.path.join(fixture_dir, 'BallMAP-Template.xlsx')
style_bl = read_template_styles(bl_path)
style_bm = read_template_styles(bm_path)
assert style_bl and style_bm, "两个模板都应该成功加载"
# MAP->List 方向:用 BallList 模板
pinlist_data = {'A1': 'QFP-44', 'A2': 'Pin1', 'B2': '1'}
pinlist_path = os.path.join(tmpdir, 'v15_010_pinlist.xlsx')
write_xlsx_with_style(pinlist_data, pinlist_path, style_bl)
# List->MAP 方向:用 BallMAP 模板
entries = [PinListEntry(number=i+1, name=f"PIN{i+1:02d}") for i in range(12)]
pinmap_path = os.path.join(tmpdir, 'v15_010_pinmap.xlsx')
generate_pinmap(entries, 3, 3, "QFP-12", template_style=style_bm, output_path=pinmap_path)
import zipfile
with zipfile.ZipFile(pinlist_path, 'r') as zf:
pl_styles = zf.read('xl/styles.xml').decode('utf-8')
with zipfile.ZipFile(pinmap_path, 'r') as zf:
pm_styles = zf.read('xl/styles.xml').decode('utf-8')
assert '楷体' in pl_styles, "BallList 输出应包含楷体"
assert '宋体' in pm_styles, "BallMAP 输出应包含宋体"
result.ok("两个方向输出字体不同: BL->楷体, BM->宋体")
r.run("TC-v1.5-010: 两个方向不同模板各自的格式", _tc_v15_010)
# ── TC-v1.5-011: 完整往返+模板隔离 ──
def _tc_v15_011(result):
bl_path = os.path.join(fixture_dir, 'BallList-Template.xlsx')
bm_path = os.path.join(fixture_dir, 'BallMAP-Template.xlsx')
style_bl = read_template_styles(bl_path)
style_bm = read_template_styles(bm_path)
assert style_bl and style_bm, "两个模板都应该成功加载"
fixture_4x4 = os.path.join(fixture_dir, 'sample_4x4.xlsx')
cells = read_xlsx_cells(fixture_4x4)
pinmap = parse_pinmap(cells)
validation = validate_pinmap(pinmap)
pinlist = generate_pinlist(pinmap, validation)
pinlist_data = {'A1': pinlist.package_info}
for i, (pin_name, pin_num) in enumerate(pinlist.rows):
pinlist_data[f'A{i+2}'] = pin_name
pinlist_data[f'B{i+2}'] = str(pin_num)
pinlist_path = os.path.join(tmpdir, 'v15_011_pinlist.xlsx')
write_xlsx_with_style(pinlist_data, pinlist_path, style_bl)
pkg2, entries2 = parse_pinlist(pinlist_path)
pinmap_path = os.path.join(tmpdir, 'v15_011_pinmap.xlsx')
generate_pinmap(entries2, 3, 3, pkg2, template_style=style_bm, output_path=pinmap_path)
rt_cells = read_xlsx_cells(pinmap_path)
rt_pinmap = parse_pinmap(rt_cells)
assert len(rt_pinmap.pins) == len(pinmap.pins), f"往返后引脚数应一致: {len(rt_pinmap.pins)} vs {len(pinmap.pins)}"
rt_numbers = sorted([p.number for p in rt_pinmap.pins])
orig_numbers = sorted([p.number for p in pinmap.pins])
assert rt_numbers == orig_numbers, f"往返引脚序号应一致"
import zipfile
with zipfile.ZipFile(pinlist_path, 'r') as zf:
pl_xml = zf.read('xl/styles.xml').decode('utf-8')
with zipfile.ZipFile(pinmap_path, 'r') as zf:
pm_xml = zf.read('xl/styles.xml').decode('utf-8')
assert '楷体' in pl_xml, "中间 PinList 应使用 BallList 的楷体"
assert '宋体' in pm_xml, "最终 PinMAP 应使用 BallMAP 的宋体"
result.ok(f"往返成功: {len(pinmap.pins)} pins, 楷体->PinList, 宋体->PinMAP")
r.run("TC-v1.5-011: 完整往返+模板隔离", _tc_v15_011)
# ── TC-v1.5-012: 无模板完整流程 ──
def _tc_v15_012(result):
fixture_4x4 = os.path.join(fixture_dir, 'sample_4x4.xlsx')
cells = read_xlsx_cells(fixture_4x4)
pinmap = parse_pinmap(cells)
validation = validate_pinmap(pinmap)
pinlist = generate_pinlist(pinmap, validation)
pinlist_data = {'A1': pinlist.package_info}
for i, (pin_name, pin_num) in enumerate(pinlist.rows):
pinlist_data[f'A{i+2}'] = pin_name
pinlist_data[f'B{i+2}'] = str(pin_num)
pinlist_path = os.path.join(tmpdir, 'v15_012_pinlist.xlsx')
write_xlsx_with_style(pinlist_data, pinlist_path, None)
assert os.path.exists(pinlist_path), "输出文件应存在"
rt_cells = read_xlsx_cells(pinlist_path)
assert (0, 0) in rt_cells, "A1 应有封装信息"
pkg2, entries2 = parse_pinlist(pinlist_path)
pinmap_path = os.path.join(tmpdir, 'v15_012_pinmap.xlsx')
generate_pinmap(entries2, 3, 3, pkg2, template_style=None, output_path=pinmap_path)
assert os.path.exists(pinmap_path), "PinMAP 输出文件应存在"
result.ok("无模板完整流程正常")
r.run("TC-v1.5-012: 无模板完整流程", _tc_v15_012)
# ── TC-v1.5-013: 极简模板(只有字体) ──
def _tc_v15_013(result):
template_path = os.path.join(fixture_dir, 'template_minimal.xlsx')
assert os.path.exists(template_path), f"极简模板文件不存在: {template_path}"
style = read_template_styles(template_path)
assert style is not None, "极简模板应成功加载"
assert len(style.fonts) > 0, "应有字体定义"
assert style.fonts[0].name == "Courier New", f"字体应为 Courier New, 实际: {style.fonts[0].name}"
assert len(style.borders) == 0, "极简模板不应有边框"
assert len(style.fills) == 0, "极简模板不应有填充"
pinlist_data = {'A1': 'QFP-8', 'A2': 'Pin1', 'B2': '1'}
outpath = os.path.join(tmpdir, 'v15_013_output.xlsx')
write_xlsx_with_style(pinlist_data, outpath, style)
import zipfile
with zipfile.ZipFile(outpath, 'r') as zf:
styles_xml = zf.read('xl/styles.xml').decode('utf-8')
assert 'Courier New' in styles_xml, "输出应包含 Courier New"
result.ok(f"极简模板: font={style.fonts[0].name}")
r.run("TC-v1.5-013: 极简模板(只有字体)", _tc_v15_013)
# ── TC-v1.5-014: 列宽扩展 ──
def _tc_v15_014(result):
template_path = os.path.join(fixture_dir, 'template_narrow.xlsx')
style = read_template_styles(template_path)
assert style is not None, "窄模板应成功加载"
assert 0 in style.column_widths, "应有第 0 列宽"
assert abs(style.column_widths[0] - 15.0) < 0.5
entries = [PinListEntry(number=i+1, name=f"PIN{i+1:02d}") for i in range(20)]
outpath = os.path.join(tmpdir, 'v15_014_output.xlsx')
generate_pinmap(entries, 5, 5, "QFN-20", template_style=style, output_path=outpath)
import zipfile
import xml.etree.ElementTree as ET
_S = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
with zipfile.ZipFile(outpath, 'r') as zf:
sheet_xml = zf.read('xl/worksheets/sheet1.xml')
root = ET.fromstring(sheet_xml)
cols_elem = root.find(f'{{{_S}}}cols')
assert cols_elem is not None, "输出应包含 cols"
cols = cols_elem.findall(f'{{{_S}}}col')
assert len(cols) >= 6, f"需要至少 6 列,实际 {len(cols)}"
widths = [float(c.get('width', '0')) for c in cols]
assert abs(widths[0] - 15.0) < 0.5, f"A列应为 15.0, 实际: {widths[0]}"
assert abs(widths[1] - 12.0) < 0.5, f"B列应为 12.0, 实际: {widths[1]}"
assert abs(widths[2] - 10.0) < 0.5, f"C列应为 10.0, 实际: {widths[2]}"
assert abs(widths[3] - 8.0) < 0.5, f"D列应为默认 8.0, 实际: {widths[3]}"
assert abs(widths[4] - 8.0) < 0.5, f"E列应为默认 8.0, 实际: {widths[4]}"
result.ok(f"列宽扩展正确: A={widths[0]}, B={widths[1]}, C={widths[2]}, D={widths[3]}, E={widths[4]}")
r.run("TC-v1.5-014: 列宽扩展", _tc_v15_014)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
# ── Main ──────────────────────────────────────────────────────────── # ── Main ────────────────────────────────────────────────────────────
def main(): def main():
@@ -593,6 +915,10 @@ def main():
test_list_to_map(runner) test_list_to_map(runner)
print() print()
print("-- Part 3: v1.5 模板/样式集成测试 --")
test_v15_styles(runner)
print()
total, passed, failed = runner.summary() total, passed, failed = runner.summary()
print("=" * 60) print("=" * 60)
print(f" 总计: {total} | 通过: {passed} | 失败: {failed}") print(f" 总计: {total} | 通过: {passed} | 失败: {failed}")
@@ -625,13 +951,17 @@ def generate_report(runner: TestRunner, total: int, passed: int, failed: int):
# Count by category # Count by category
map_results = [r for r in runner.results if r.name.startswith("TC-MAP")] map_results = [r for r in runner.results if r.name.startswith("TC-MAP")]
lm_results = [r for r in runner.results if r.name.startswith("TC-LM")] lm_results = [r for r in runner.results if r.name.startswith("TC-LM")]
v15_results = [r for r in runner.results if r.name.startswith("TC-v1.5")]
map_pass = sum(1 for r in map_results if r.passed) map_pass = sum(1 for r in map_results if r.passed)
map_fail = len(map_results) - map_pass map_fail = len(map_results) - map_pass
lm_pass = sum(1 for r in lm_results if r.passed) lm_pass = sum(1 for r in lm_results if r.passed)
lm_fail = len(lm_results) - lm_pass lm_fail = len(lm_results) - lm_pass
v15_pass = sum(1 for r in v15_results if r.passed)
v15_fail = len(v15_results) - v15_pass
lines.append(f"| MAPList 回归 | {len(map_results)} | {map_pass} | {map_fail} |") lines.append(f"| MAP->List 回归 | {len(map_results)} | {map_pass} | {map_fail} |")
lines.append(f"| ListMAP 新增 | {len(lm_results)} | {lm_pass} | {lm_fail} |") lines.append(f"| List->MAP 新增 | {len(lm_results)} | {lm_pass} | {lm_fail} |")
lines.append(f"| v1.5 模板/样式集成 | {len(v15_results)} | {v15_pass} | {v15_fail} |")
lines.append(f"| **总计** | **{total}** | **{passed}** | **{failed}** |") lines.append(f"| **总计** | **{total}** | **{passed}** | **{failed}** |")
lines.append("") lines.append("")
lines.append("---") lines.append("---")
@@ -663,6 +993,19 @@ def generate_report(runner: TestRunner, total: int, passed: int, failed: int):
lines.append(f"- **错误**: {r.error}") lines.append(f"- **错误**: {r.error}")
lines.append("") lines.append("")
# v1.5 details
lines.append("## Part 3: v1.5 模板/样式集成测试")
lines.append("")
for r in v15_results:
status = "✅ 通过" if r.passed else "❌ 失败"
lines.append(f"### {r.name}")
lines.append(f"- **结果**: {status}")
if r.details:
lines.append(f"- **详情**: {r.details}")
if r.error:
lines.append(f"- **错误**: {r.error}")
lines.append("")
# Summary # Summary
lines.append("---") lines.append("---")
lines.append("") lines.append("")

680
Test/test_plan_v1.5.md Normal file
View File

@@ -0,0 +1,680 @@
# PinMAP-to-PinList v1.5.0 — 测试方案
> **版本**: v1.5.0
> **日期**: 2026-06-06
> **设计人**: 测试架构师 (Test Architect)
> **基准**: 修改需求评估 `docs/modification-assessment-v1.5.md`
---
## 1. 变更影响范围
| 编号 | 标题 | 影响文件 | 测试重点 |
|------|------|---------|---------|
| F009 | MAP→List 使用 BallList-Template.xlsx | `main.py` | 模板分离后方向独立,互不干扰 |
| F010 | List→MAP 使用 BallMAP-Template.xlsx | `main.py` | 模板分离后方向独立,互不干扰 |
| F011 | 模板格式提取式应用 | `xlsx_writer.py`, `template_reader.py` | 字体/边框/填充/对齐/列宽/行高正确应用 |
| F012 | PinName 位置确认 + 回归测试 | `test_pinmap.py` | 上/下边 PinName 位置正确,往返一致性 |
---
## 2. 已有测试覆盖分析
### 2.1 单元测试 (`Code/src/test_pinmap.py`) — 9 个用例,全部通过
| 用例 | 覆盖范围 | v1.5 是否受影响 |
|------|---------|----------------|
| `test_4x4_parse` | 4×4 PinMAP 解析 (left/bottom/right/top) | ✅ 已验证 bottom/right Name 位置正确 |
| `test_4x4_validate` | 4×4 PinMAP 验证 | 不受影响 |
| `test_missing_names_warning` | 缺失 Name 警告 | 不受影响 |
| `test_duplicate_numbers` | 重复序号错误 | 不受影响 |
| `test_gap_in_numbers` | 序号不连续错误 | 不受影响 |
| `test_empty_cells` | 空单元格异常 | 不受影响 |
| `test_no_pins` | 无 Pin 数据异常 | 不受影响 |
| `test_12pin_square` | 6×6 12Pin 解析和验证 | 不受影响 |
| `test_f012_pinname_position` | **v1.5 新增** — 5×5 20Pin 往返一致性 | ✅ **v1.5 核心回归测试** |
### 2.2 集成测试 (`Test/run_tests.py`) — 23 个用例,全部通过
| 编号 | 用例 | v1.5 是否受影响 |
|------|------|----------------|
| TC-MAP-001~006 | MAP→List 回归(含错误/警告场景) | ⚠️ F009 影响模板查找逻辑 |
| TC-LM-001~016 | List→MAP 新增(含错误/警告/往返) | ⚠️ F010/F011 影响模板样式应用 |
### 2.3 已有测试缺口v1.5 前)
1. **无模板相关测试** — 现有集成测试仅在 `TC-LM-003` 中使用临时创建的 fake 模板验证 styles.xml 存在,未测试真实的 BallList/BallMAP 模板分离
2. **无样式正确性验证** — 现有测试仅验证 `styles.xml` 文件存在,未验证字体/边框/填充/对齐/列宽/行高的实际内容
3. **无模板降级测试** — 未测试模板不存在/解析失败时的优雅降级行为
4. **无两方向独立模板测试** — 未验证 MAP→List 和 List→MAP 使用不同模板时的隔离性
---
## 3. 完整测试方案
### 3.1 测试分层策略
| 层级 | 位置 | 测试什么 | 执行方式 |
|------|------|---------|---------|
| **Unit** | `Code/src/test_pinmap.py` | 纯逻辑测试(解析/验证/布局/往返),不依赖文件系统 | `python3 Code/src/test_pinmap.py` |
| **Integration** | `Test/run_tests.py` | 文件级测试xlsx 读写/模板加载/端到端),依赖 fixtures/ 和临时文件 | `python3 Test/run_tests.py` |
### 3.2 测试原则
1. **单元测试** (`test_pinmap.py`):纯逻辑测试,不读/写磁盘文件,使用内存中的 cell dict
2. **集成测试** (`run_tests.py`):文件级测试,使用 `Test/fixtures/` 中的真实 .xlsx 文件和临时目录中的动态创建文件
3. **模板 fixture**:需要准备 `BallList-Template.xlsx``BallMAP-Template.xlsx` 两个模板 fixture 文件
---
## 4. F012 — PinName 位置回归测试
### 4.1 状态确认
**代码当前行为**
- `pinmap_layout.py::get_name_cell`: bottom → `(r-1, c)`, top → `(r+1, c)`
- `pinmap_parser.py`: bottom name 读自 `(max_row-1, c)`, top name 读自 `(min_row+1, c)`
- 生成与解析使用**相同约定**,往返一致 ✅
**F012 测试确认**:当前代码已正确,测试已存在并全部通过。
### 4.2 已有回归测试(无需新增)
| 用例 | 位置 | 覆盖 |
|------|------|------|
| `test_f012_pinname_position` | `test_pinmap.py` | 5×5 20Pin 往返:生成 → 逐个验证四条边 Name 位置 → 解析回 PinList → 验证序号一致性 |
### 4.3 测试数据
```
test_pinmap.py 中的 4×4 数据已验证:
bottom: numbers at (6,2)=3, (6,3)=4; names at (5,2)=Pin3, (5,3)=Pin4 → max_row-1 ✅
top: numbers at (1,3)=7, (1,2)=8; names at (2,3)=Pin7, (2,2)=Pin8 → min_row+1 ✅
test_f012 5×5 数据已验证:
bottom: names at (4, c) = rows-1 = max_row-1 ✅
top: names at (2, c) = min_row+1 ✅
left: names at (r, 1) = c+1 ✅
right: names at (r, 4) = c-1 ✅
```
### 4.4 F012 测试结论
**无需新增测试**`test_f012_pinname_position` 已充分覆盖 F012 需求,且当前测试全部通过。保持该测试持续运行即可。
---
## 5. F009 + F010 — 模板分离测试
### 5.1 测试场景矩阵
| 场景 | BallList-Template | BallMAP-Template | MAP→List 预期 | List→MAP 预期 |
|------|-------------------|------------------|-------------|-------------|
| T-F009-01 | ✅ 存在 | — | 加载 BallList应用其样式 | (不涉及) |
| T-F009-02 | ❌ 不存在 | — | 日志提示"未检测到",使用默认样式 | (不涉及) |
| T-F009-03 | 存在但损坏 | — | 日志提示"解析失败",使用默认样式 | (不涉及) |
| T-F010-01 | — | ✅ 存在 | (不涉及) | 加载 BallMAP应用其样式 |
| T-F010-02 | — | ❌ 不存在 | (不涉及) | 日志提示"未检测到",使用默认样式 |
| T-F010-03 | — | 存在但损坏 | (不涉及) | 日志提示"解析失败",使用默认样式 |
| T-F009-04 | ✅ 存在 | ✅ 存在 | 加载 BallList | 加载 BallMAP各自独立互不干扰 |
### 5.2 关键验证点
1. **MAP→List 不使用 BallMAP 模板** — 即使 BallMAP-Template.xlsx 存在MAP→List 也不会加载它
2. **List→MAP 不使用 BallList 模板** — 即使 BallList-Template.xlsx 存在List→MAP 也不会加载它
3. **不加载旧 PinMAP-Template.xlsx** — 即使旧模板存在,两个方向都不加载
### 5.3 单元测试:新增到 `test_pinmap.py`
以下测试**可**新增到 `test_pinmap.py`(纯逻辑,不依赖文件系统):
#### U-F009-F010-001: `_find_balllist_template_path` 和 `_find_ballmap_template_path` 路径生成
```python
def test_template_path_generation():
"""验证两个模板查找函数返回正确的路径格式。"""
import os
from main import _find_balllist_template_path, _find_ballmap_template_path
# 路径应以项目根目录为基础,包含正确的文件名
# 注意:这些函数检查文件是否存在,测试环境可能没有这些文件
# 所以只验证函数可调用且返回类型正确
result1 = _find_balllist_template_path()
result2 = _find_ballmap_template_path()
# 返回值要么是 str 要么是 None
assert result1 is None or isinstance(result1, str)
assert result2 is None or isinstance(result2, str)
# 两者应该是不同路径
if result1 and result2:
assert "BallList" in result1
assert "BallMAP" in result2
assert result1 != result2
print("✓ test_template_path_generation passed")
```
### 5.4 集成测试:新增到 `Test/run_tests.py`
以下测试需要 `Test/fixtures/` 中的模板文件:
**Fixture 准备**
| 文件 | 用途 | 内容要求 |
|------|------|---------|
| `Test/fixtures/BallList-Template.xlsx` | MAP→List 模板 | 至少包含 fonts(Calibri 12pt, bold), borders(thin), column_widths |
| `Test/fixtures/BallMAP-Template.xlsx` | MAP→List 模板 | 至少包含 fonts(Arial 10pt), borders(medium), row_heights |
| `Test/fixtures/template_corrupt.xlsx` | 损坏模板测试 | 一个非 xlsx 的普通文件伪装为 .xlsx |
**新增集成测试用例**
#### TC-v1.5-001: MAP→List 加载 BallList 模板(模板存在)
```
前置: fixtures/BallList-Template.xlsx 存在
步骤:
1. 创建 PinMAP 输入4×4
2. 模拟 run_map_to_list 模板加载流程
3. 验证 read_template_styles 返回非 None
4. 验证返回的 TemplateStyle 包含字体信息
预期: 模板样式成功加载,输出文件包含 styles.xml
```
#### TC-v1.5-002: MAP→List 无模板降级(模板不存在)
```
前置: 删除根目录的 BallList-Template.xlsx
步骤:
1. 创建 PinMAP 输入4×4
2. 调用 _find_balllist_template_path()
3. 验证返回 None
预期: 返回 None输出使用默认样式
```
#### TC-v1.5-003: List→MAP 加载 BallMAP 模板(模板存在)
```
前置: fixtures/BallMAP-Template.xlsx 存在
步骤:
1. 创建 5×5 PinList 输入20 pin
2. 模拟 run_list_to_map 模板加载流程
3. 验证 read_template_styles 返回非 None
4. 生成 PinMAP验证输出包含 styles.xml
预期: 模板样式成功加载,输出文件包含 styles.xml
```
#### TC-v1.5-004: List→MAP 无模板降级(模板不存在)
```
前置: 删除根目录的 BallMAP-Template.xlsx
步骤:
1. 创建 5×5 PinList 输入20 pin
2. 调用 _find_ballmap_template_path()
3. 验证返回 None
预期: 返回 None输出使用默认样式
```
#### TC-v1.5-005: 两个方向独立使用各自模板
```
前置: BallList-Template.xlsx 和 BallMAP-Template.xlsx 都存在
步骤:
1. MAP→List 方向:调用 _find_balllist_template_path(),验证路径包含 "BallList"
2. List→MAP 方向:调用 _find_ballmap_template_path(),验证路径包含 "BallMAP"
3. 验证两个路径不同
预期: 两个方向各自查找各自的模板文件,互不干扰
```
#### TC-v1.5-006: 模板损坏时优雅降级
```
前置: 提供一个损坏的 "template_corrupt.xlsx"
步骤:
1. 调用 read_template_styles("fixtures/template_corrupt.xlsx")
2. 验证返回 None不抛异常
预期: 返回 None调用方可继续使用默认样式
```
---
## 6. F011 — 模板格式提取式应用测试
### 6.1 测试场景
F011 核心要求:模板的**格式信息**(字体/边框/填充/对齐/列宽/行高)正确应用到输出文件,但输出文件的**行列结构**由实际 Pin 数量决定。
### 6.2 单元测试:新增到 `test_pinmap.py`
F011 的样式构建逻辑在 `xlsx_writer.py::StyledXLSXWriter` 中,涉及 XML 字符串生成。以下测试可以在不写磁盘文件的情况下验证 XML 内容:
#### U-F011-001: 无模板时使用默认样式
```python
def test_f011_default_styles_xml():
"""F011: 无模板时 _styles_xml() 返回硬编码默认样式。"""
from xlsx_writer import StyledXLSXWriter
writer = StyledXLSXWriter(style=None)
xml = writer._styles_xml()
# 验证硬编码默认值的存在
assert 'Calibri' in xml, "默认字体应为 Calibri"
assert 'thin' in xml or 'style="thin"' in xml, "默认边框应为 thin"
assert 'center' in xml, "默认对齐应为 center"
assert 'cellXfs count="4"' in xml, "应有 4 个 xf"
print("✓ test_f011_default_styles_xml passed")
```
#### U-F011-002: 有模板时使用模板字体
```python
def test_f011_template_fonts_in_styles_xml():
"""F011: 有模板时 _styles_xml() 使用模板的字体信息(而非硬编码 Calibri"""
from template_reader import TemplateStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
# 构建一个模板样式:微软雅黑 12pt + bold
style = TemplateStyle()
style.fonts = [
FontStyle(name="微软雅黑", size=12.0, bold=False, italic=False, color="FF000000"),
FontStyle(name="微软雅黑", size=12.0, bold=True, italic=False, color="FF000000"),
]
style.fills = []
style.borders = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
assert '微软雅黑' in xml, f"模板字体名应出现在 styles.xml 中\n{xml[:500]}"
assert '12' in xml or '12.0' in xml, f"模板字号 12pt 应出现在 styles.xml 中"
print("✓ test_f011_template_fonts_in_styles_xml passed")
```
#### U-F011-003: 有模板时使用模板边框
```python
def test_f011_template_borders_in_styles_xml():
"""F011: 有模板时 _styles_xml() 使用模板的边框样式(而非硬编码 thin"""
from template_reader import TemplateStyle, BorderStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [FontStyle(name="Calibri", size=11.0)]
style.borders = [
BorderStyle(top="none", bottom="none", left="none", right="none"),
BorderStyle(top="medium", bottom="medium", left="medium", right="medium"),
]
style.fills = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '1', 'xfId': '0',
'applyBorder': '1'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
# 模板的 medium 边框应该存在(不仅仅是 thin
assert 'medium' in xml, f"模板 medium 边框应出现在 styles.xml 中\n{xml[:800]}"
print("✓ test_f011_template_borders_in_styles_xml passed")
```
#### U-F011-004: 有模板时使用模板填充
```python
def test_f011_template_fills_in_styles_xml():
"""F011: 有模板时 _styles_xml() 使用模板的填充色(而非硬编码 FFF0F0F0"""
from template_reader import TemplateStyle, FillStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [FontStyle(name="Calibri", size=11.0)]
style.borders = []
style.fills = [
FillStyle(pattern_type="none", fg_color=""),
FillStyle(pattern_type="solid", fg_color="FFFF00"), # 黄色
]
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
{'numFmtId': '0', 'fontId': '0', 'fillId': '1', 'borderId': '0', 'xfId': '0',
'applyFill': '1'},
]
writer = StyledXLSXWriter(style=style)
xml = writer._styles_xml()
assert 'FFFF00' in xml, f"模板黄色填充应出现在 styles.xml 中\n{xml[:800]}"
print("✓ test_f011_template_fills_in_styles_xml passed")
```
#### U-F011-005: 输出行列由实际 Pin 决定(不复制模板行列结构)
```python
def test_f011_output_dims_determined_by_pins():
"""F011: 输出文件的 dim 由实际 Pin 数量决定,不复制模板的行列结构。
即使模板有 100 行列定义,输出仍只包含实际 Pin 数据的行列。
"""
from template_reader import TemplateStyle, FontStyle
from xlsx_writer import StyledXLSXWriter
style = TemplateStyle()
style.fonts = [FontStyle(name="Calibri", size=11.0)]
style.column_widths = {i: 20.0 for i in range(100)} # 模板有 100 列
style.row_heights = {i: 30.0 for i in range(200)} # 模板有 200 行
style.fills = []
style.borders = []
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
]
# 仅输出 2 行 3 列的数据(模拟 2×2 PinMAP + A1
data = {
'A1': 'QFP-8',
'A2': '1', 'B2': 'Pin1',
'A3': '2', 'B3': 'Pin2',
}
writer = StyledXLSXWriter(style=style)
sheet_xml = writer._sheet_xml(data)
# dim 应该反映实际数据范围A1:C3而非模板的 100 列
assert 'dimension ref="A1:C' in sheet_xml or 'dimension ref="A1:C3"' in sheet_xml, \
f"dim 应由实际数据决定,不应包含模板的 100 列\n{sheet_xml[:500]}"
# 不应出现 row r="201"(模板的第 200 行)
assert 'row r="201"' not in sheet_xml, "不应包含模板的多余行"
print("✓ test_f011_output_dims_determined_by_pins passed")
```
### 6.3 集成测试:新增到 `Test/run_tests.py`
#### TC-v1.5-007: 模板字体应用到输出文件
```
前置: fixtures/BallMAP-Template.xlsx 使用微软雅黑 14pt
步骤:
1. 用该模板生成 5×5 PinMAP 输出
2. 解压输出 xlsx读取 xl/styles.xml
3. 验证 fonts 部分包含 "微软雅黑" 和 size=14
预期: 输出 styles.xml 包含模板字体定义
```
#### TC-v1.5-008: 模板列宽应用到输出文件
```
前置: fixtures/BallList-Template.xlsx 中 A 列宽=25, B 列宽=18
步骤:
1. 用该模板生成 PinList 输出
2. 解压输出 xlsx读取 xl/worksheets/sheet1.xml
3. 验证 cols 元素中 A 列 width=25, B 列 width=18
预期: 输出列宽与模板一致
```
#### TC-v1.5-009: 模板行高应用到输出文件
```
前置: fixtures/BallMAP-Template.xlsx 中行高=25
步骤:
1. 用该模板生成 3×3 PinMAP 输出
2. 解压输出 xlsx读取 xl/worksheets/sheet1.xml
3. 验证 row 元素包含 ht=25
预期: 输出行高与模板一致
```
#### TC-v1.5-010: 两个方向使用不同模板各自的格式
```
前置:
- BallList-Template.xlsx 字体=楷体 12pt
- BallMAP-Template.xlsx 字体=宋体 14pt
步骤:
1. MAP→List 方向:用 BallList 模板生成 PinList验证输出字体=楷体 12pt
2. List→MAP 方向:用 BallMAP 模板生成 PinMAP验证输出字体=宋体 14pt
3. 验证两个输出文件的字体不同
预期: 两个方向的输出各自使用对应模板的字体
```
---
## 7. F009/F010/F011 集成测试
### 7.1 端到端集成测试
#### TC-v1.5-011: 完整往返 + 模板隔离 (MAP→List→MAP)
```
前置: BallList-Template.xlsx 和 BallMAP-Template.xlsx 都存在且格式不同
步骤:
1. 使用 BallList 模板,执行 sample_4x4.xlsx → PinList
2. 验证 PinList 输出包含 BallList 模板的格式特征
3. 使用 BallMAP 模板,执行 PinList → PinMAP
4. 验证 PinMAP 输出包含 BallMAP 模板的格式特征
5. 验证往返后的 PinMAP 与原 PinMAP 数据一致(忽略格式差异)
预期:
- 往返数据完全一致(引脚序号/名称/封装信息)
- 中间 PinList 使用 BallList 模板格式
- 最终 PinMAP 使用 BallMAP 模板格式
```
#### TC-v1.5-012: 无模板完整流程
```
前置: 根目录没有 BallList-Template.xlsx 也没有 BallMAP-Template.xlsx
步骤:
1. 执行 MAP→List 转换
2. 执行 List→MAP 转换
3. 验证两个输出文件都能正常生成且数据正确
4. 验证两个输出文件使用默认样式Calibri 11pt
预期: 无模板情况下正常降级,输出使用默认样式,数据正确
```
---
## 8. 边界与异常测试
### 8.1 模板边界测试
| 编号 | 场景 | 预期行为 | 测试级别 |
|------|------|---------|---------|
| TC-BND-001 | 模板 fonts 为空列表 | 回退到默认字体 | Unit |
| TC-BND-002 | 模板 borders 为空列表 | 回退到默认边框thin | Unit |
| TC-BND-003 | 模板 fills 为空列表 | 回退到默认填充 | Unit |
| TC-BND-004 | 模板 cell_xfs 为空列表 | 回退到默认 cellXfs4 个硬编码 xf | Unit |
| TC-BND-005 | 模板 column_widths 为空 dict | 使用默认列宽 8.0 | Unit |
| TC-BND-006 | 模板 row_heights 为空 dict | 不使用自定义行高(使用 Excel 默认) | Unit |
| TC-BND-007 | 模板字体 color 缺少 FF 前缀 | xlsx_writer 自动补全 | Unit |
| TC-BND-008 | 模板填充 fg_color 缺少 FF 前缀 | xlsx_writer 自动补全 | Unit |
| TC-BND-009 | 模板中缺失 styles.xml | read_template_styles 返回 None | Unit |
| TC-BND-010 | 模板中缺失 sheet1.xml | column_widths/row_heights 为空 | Unit |
### 8.2 边界场景集成测试
#### TC-v1.5-013: 模板只有字体、无边框/填充
```
前置: 准备一个只有 fonts 定义的极简模板
步骤:
1. 加载模板样式
2. 验证 fonts 正确提取
3. 验证 borders/fills 使用默认值
4. 生成的输出文件可正常打开
预期: 字体来自模板,边框/填充使用默认值,文件可正常打开
```
#### TC-v1.5-014: 模板列宽少于输出列数
```
前置: 模板定义 3 列宽,但输出需要 6 列
步骤:
1. 加载模板(定义 A-C 列宽)
2. 生成 5×5(6 列) PinMAP
3. 验证模板定义的列使用模板宽度,额外的列使用默认 8.0
预期: 列宽正确扩展,无异常
```
---
## 9. 测试执行计划
### 9.1 新增单元测试(添加到 `test_pinmap.py`
| 优先级 | 编号 | 测试名 | 简述 |
|--------|------|--------|------|
| P0 | U-F009-F010-001 | `test_template_path_generation` | 验证模板路径格式正确且互不相同 |
| P0 | U-F011-001 | `test_f011_default_styles_xml` | 无模板时使用默认样式 |
| P0 | U-F011-002 | `test_f011_template_fonts_in_styles_xml` | 模板字体应用到 styles.xml |
| P1 | U-F011-003 | `test_f011_template_borders_in_styles_xml` | 模板边框应用到 styles.xml |
| P1 | U-F011-004 | `test_f011_template_fills_in_styles_xml` | 模板填充应用到 styles.xml |
| P0 | U-F011-005 | `test_f011_output_dims_determined_by_pins` | 输出 dim 由 Pin 数决定 |
| P1 | U-BND-001 | `test_template_empty_fonts_fallback` | 空 fonts 回退 |
| P1 | U-BND-007 | `test_template_color_prefix_auto_fix` | FF 前缀补全 |
| P1 | U-BND-009 | `test_template_no_styles_xml` | 缺失 styles.xml 降级 |
### 9.2 新增集成测试(添加到 `Test/run_tests.py`
| 优先级 | 编号 | 测试名 | 简述 | 需要 Fixture |
|--------|------|--------|------|------------|
| P0 | TC-v1.5-001 | MAP→List 加载 BallList 模板 | 模板存在时正确加载 | BallList-Template.xlsx |
| P0 | TC-v1.5-002 | MAP→List 无模板降级 | 模板不存在时优雅降级 | 无 |
| P0 | TC-v1.5-003 | List→MAP 加载 BallMAP 模板 | 模板存在时正确加载 | BallMAP-Template.xlsx |
| P0 | TC-v1.5-004 | List→MAP 无模板降级 | 模板不存在时优雅降级 | 无 |
| P0 | TC-v1.5-005 | 两个方向独立模板 | 各自使用各自的模板 | 两个模板 |
| P1 | TC-v1.5-006 | 模板损坏优雅降级 | 损坏模板不抛异常 | template_corrupt.xlsx |
| P1 | TC-v1.5-007 | 模板字体应用到输出 | 输出文件含模板字体 | 特殊字体模板 |
| P1 | TC-v1.5-008 | 模板列宽应用到输出 | 输出列宽=模板列宽 | 特殊列宽模板 |
| P1 | TC-v1.5-009 | 模板行高应用到输出 | 输出行高=模板行高 | 特殊行高模板 |
| P1 | TC-v1.5-010 | 两个方向各自格式 | 两个方向格式独立 | 两个不同格式模板 |
| P1 | TC-v1.5-011 | 完整往返+模板隔离 | MAP→List→MAP 数据一致 | 两个模板 |
| P1 | TC-v1.5-012 | 无模板完整流程 | 无模板正常降级 | 无 |
| P2 | TC-v1.5-013 | 极简模板 | 只有字体的模板 | 极简模板 |
| P2 | TC-v1.5-014 | 列宽扩展 | 模板列宽少于输出列数 | 窄模板 |
---
## 10. Fixture 准备清单
### 10.1 需要创建的 Fixture 文件
| 文件 | 放置位置 | 用途 | 关键内容 |
|------|---------|------|---------|
| `BallList-Template.xlsx` | `Test/fixtures/` | MAP→List 模板 | 字体=楷体 12pt, A列宽=25, B列宽=18, 边框=thin, 对齐=center |
| `BallMAP-Template.xlsx` | `Test/fixtures/` | List→MAP 模板 | 字体=宋体 14pt, 行高=25, 边框=medium, 填充=淡黄 FFFFFF00 |
| `template_corrupt.xlsx` | `Test/fixtures/` | 损坏模板测试 | 一个无效的 ZIP 文件或文本文件伪装为 .xlsx |
| `template_minimal.xlsx` | `Test/fixtures/` | 极简模板测试 | 只有 1 个 font 定义,无 borders/fills |
| `template_narrow.xlsx` | `Test/fixtures/` | 列宽扩展测试 | 只定义 3 列宽A-C但测试输出 6 列 |
### 10.2 集成测试时的模板放置策略
集成测试需要能临时将模板放到正确位置。建议方案:
**方案 A推荐**:在 `run_tests.py` 中使用 `tempfile.mkdtemp` 创建临时目录,将 fixture 模板复制为 `BallList-Template.xlsx` / `BallMAP-Template.xlsx`,然后通过 `os.chdir` 或修改 `sys.path` 让 main.py 的模板查找逻辑找到它们。
**方案 B**:在 `run_tests.py` 中直接调用底层函数(`read_template_styles`, `_find_balllist_template_path`),不经过 main.py 的路径查找,而是直接传入 fixture 路径。
推荐 **方案 B**(更简洁),因为已有测试也是直接调用底层函数。
### 10.3 Fixture 创建方法
使用 `xlsx_writer.py::write_xlsx_with_style` 创建带特定格式的模板文件:
```python
# 创建 BallList-Template.xlsx
from xlsx_writer import StyledXLSXWriter
from template_reader import TemplateStyle, FontStyle, BorderStyle, FillStyle
style = TemplateStyle()
style.fonts = [
FontStyle(name="楷体", size=12.0, bold=False, color="FF000000"),
FontStyle(name="楷体", size=12.0, bold=True, color="FF000000"),
]
style.borders = [
BorderStyle(top="none", bottom="none", left="none", right="none"),
BorderStyle(top="thin", bottom="thin", left="thin", right="thin"),
]
style.fills = [FillStyle(pattern_type="none")]
style.cell_xfs = [
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '0', 'xfId': '0'},
{'numFmtId': '0', 'fontId': '0', 'fillId': '0', 'borderId': '1', 'xfId': '0',
'applyBorder': '1', 'hAlign': 'center', 'vAlign': 'center'},
]
style.column_widths = {0: 25.0, 1: 18.0}
style.row_heights = {}
# 创建 PinList 模板数据2 行示例数据)
data = {'A1': '封装信息', 'A2': 'Pin1', 'B2': '1', 'A3': 'Pin2', 'B3': '2'}
writer = StyledXLSXWriter(style)
writer.write(data, "fixtures/BallList-Template.xlsx")
```
---
## 11. 预期工作量
| 阶段 | 内容 | 预估时间 |
|------|------|---------|
| Fixture 准备 | 创建 5 个模板 fixture 文件 | 30 min |
| 单元测试 | 新增约 10 个单元测试到 `test_pinmap.py` | 1 hr |
| 集成测试 | 新增约 14 个集成测试到 `run_tests.py` | 1.5 hr |
| 回归验证 | 运行全部测试确认无回归 | 15 min |
| 文档更新 | 更新测试报告 | 10 min |
| **总计** | | **~3.5 hr** |
---
## 12. 测试通过标准
| 条件 | 要求 |
|------|------|
| 现有 9 个单元测试 | 全部通过 |
| 现有 23 个集成测试 | 全部通过 |
| 新增 ~10 个单元测试 | 全部通过 |
| 新增 ~14 个集成测试 | 全部通过 |
| F012 往返测试 (`test_f012_pinname_position`) | 持续通过 |
| F009/F010 模板分离 | 两个方向使用各自模板,互不干扰 |
| F011 模板格式提取 | 字体/边框/填充/对齐/列宽/行高从模板正确应用 |
| 无模板场景 | 优雅降级使用默认样式,不抛异常 |
| 损坏模板场景 | 优雅降级为 None不抛异常 |
---
## 13. 总结
### 13.1 测试设计决策
| 决策 | 说明 |
|------|------|
| F012 不再新增测试 | `test_f012_pinname_position` 已充分覆盖,代码行为已确认正确 |
| F009/F010 模板分离测试侧重降级 | 核心风险在于模板不存在/解析失败时的行为,而非正常路径 |
| F011 样式测试分两层 | 单元层验证 XML 生成,集成层验证最终文件内容 |
| 单元测试不依赖文件系统 | 使用内存中的 `TemplateStyle` 对象直接构造测试数据 |
| 集成测试使用 fixture 文件 | 由 test-executor 预先创建模板 fixture再运行测试 |
### 13.2 测试覆盖矩阵
```
F012 F009 F010 F011 边界 集成
现有 test_pinmap.py ✅ — — — — —
现有 run_tests.py — — — — — ✅
新增 unit tests — ✅ ✅ ✅ ✅ —
新增 integration tests— ✅ ✅ ✅ ✅ ✅
```
### 13.3 下一步
1. **test-executor** 按本方案执行 Fixture 准备(创建 5 个模板文件)
2. **test-executor** 按优先级 P0 → P1 → P2 实施测试代码
3. 运行完整测试套件验证
4. 生成 v1.5.0 最终测试报告
---
*测试方案结束 — 等待 test-executor 执行*

View File

@@ -1,18 +1,51 @@
# PinMAP ↔ PinList 双向转换器 测试报告 # PinMAP ↔ PinList 双向转换器 测试报告 (v1.5.0)
> **日期**: 2026-05-28 > **版本**: v1.5.0
> **测试类型**: 集成测试 + 端到端测试 > **日期**: 2026-06-06
> **测试类型**: 单元测试 + 集成测试 + 端到端测试
> **测试环境**: Python 3.x, Linux x64 > **测试环境**: Python 3.x, Linux x64
--- ---
## v1.5.0 变更覆盖
v1.5.0 引入三项核心变更:
- **F009**: MAP→List 使用 BallList-Template.xlsx独立模板
- **F010**: List→MAP 使用 BallMAP-Template.xlsx独立模板
- **F011**: 模板格式提取式应用(字体/边框/填充/对齐/列宽/行高)
- **F012**: PinName 位置确认bottom=max_row-1, top=min_row+1
## 测试覆盖矩阵
| 特性 | 单元测试 | 集成测试 | 状态 |
|------|---------|---------|------|
| F009 — BallList 模板加载 | ✅ `test_template_path_generation` | ✅ TC-v1.5-001/002/005 | ✅ |
| F010 — BallMAP 模板加载 | ✅ `test_template_path_generation` | ✅ TC-v1.5-003/004/005 | ✅ |
| F011 — 模板字体应用 | ✅ `test_f011_template_fonts_in_styles_xml` | ✅ TC-v1.5-007/010/013 | ✅ |
| F011 — 模板边框应用 | ✅ `test_f011_template_borders_in_styles_xml` | ✅ TC-v1.5-007/010 | ✅ |
| F011 — 模板填充应用 | ✅ `test_f011_template_fills_in_styles_xml` | ✅ TC-v1.5-010 | ✅ |
| F011 — 默认样式降级 | ✅ `test_f011_default_styles_xml` | ✅ TC-v1.5-002/004/012 | ✅ |
| F011 — 输出 dim 由 Pin 决定 | ✅ `test_f011_output_dims_determined_by_pins` | ✅ TC-v1.5-014 | ✅ |
| F011 — 列宽应用 | — | ✅ TC-v1.5-008/014 | ✅ |
| F011 — 行高应用 | — | ✅ TC-v1.5-009 | ✅ |
| F012 — PinName 位置 | ✅ `test_f012_pinname_position` | — | ✅ |
| 损坏模板优雅降级 | — | ✅ TC-v1.5-006 | ✅ |
| 极简模板 | — | ✅ TC-v1.5-013 | ✅ |
| 无模板完整流程 | — | ✅ TC-v1.5-012 | ✅ |
| 完整往返+模板隔离 | — | ✅ TC-v1.5-011 | ✅ |
| 空 fonts/样式回退 | ✅ `test_template_empty_fonts_fallback` | — | ✅ |
| FF 颜色前缀补全 | ✅ `test_template_color_prefix_auto_fix` | — | ✅ |
| 缺失 styles.xml 降级 | ✅ `test_template_no_styles_xml` | — | ✅ |
## 测试概览 ## 测试概览
| 类别 | 用例数 | 通过 | 失败 | | 类别 | 用例数 | 通过 | 失败 |
|------|--------|------|------| |------|--------|------|------|
| MAP→List 回归 | 6 | 6 | 0 | | 单元测试 (test_pinmap.py) | **18** | **18** | **0** |
| List→MAP 新增 | 17 | 17 | 0 | | MAP->List 回归 | 6 | 6 | 0 |
| **总计** | **23** | **23** | **0** | | List->MAP 新增 | 17 | 17 | 0 |
| v1.5 模板/样式集成 | 14 | 14 | 0 |
| **总计** | **55** | **55** | **0** |
--- ---
@@ -20,7 +53,7 @@
### TC-MAP-001: 标准4x4 PinMAP转换 ### TC-MAP-001: 标准4x4 PinMAP转换
- **结果**: ✅ 通过 - **结果**: ✅ 通过
- **详情**: 封装=QFP44, Pin数=8, 序号递增 - **详情**: 封装=QFP12, Pin数=12, 序号递增
### TC-MAP-002: 长方形PinMAP转换 ### TC-MAP-002: 长方形PinMAP转换
- **结果**: ✅ 通过 - **结果**: ✅ 通过
@@ -44,13 +77,13 @@
## Part 2: List→MAP 新增功能测试 ## Part 2: List→MAP 新增功能测试
### TC-LM-001: 5×5 PinList→PinMAP (16引脚) ### TC-LM-001: 5×5 PinList→PinMAP (20引脚)
- **结果**: ✅ 通过 - **结果**: ✅ 通过
- **详情**: 解析成功, 封装=QFP-16, Pin数=16, 5×5布局验证通过 - **详情**: 解析成功, 封装=QFP-20, Pin数=20, 5×5布局验证通过
### TC-LM-002: 6×12 PinList→PinMAP (32引脚) ### TC-LM-002: 6×10 PinList→PinMAP (32引脚)
- **结果**: ✅ 通过 - **结果**: ✅ 通过
- **详情**: 解析成功, 封装=LQFP-32, Pin数=32, 6×12布局+文件输出验证通过 - **详情**: 解析成功, 封装=LQFP-32, Pin数=32, 6×10布局+文件输出验证通过
### TC-LM-003: 带模板文件的转换 ### TC-LM-003: 带模板文件的转换
- **结果**: ✅ 通过 - **结果**: ✅ 通过
@@ -66,7 +99,7 @@
### TC-LM-006: Pin总数不匹配 ### TC-LM-006: Pin总数不匹配
- **结果**: ✅ 通过 - **结果**: ✅ 通过
- **详情**: 正确报错: Pin数量与网格周长不匹配 — 网格 3×4 需要 10 个引脚,但 PinList 有 8 个 - **详情**: 正确报错: Pin数量与网格周长不匹配 — 网格 3×4 需要 14 个引脚,但 PinList 有 8 个
### TC-LM-007: 缺少PinName (warning) ### TC-LM-007: 缺少PinName (warning)
- **结果**: ✅ 通过 - **结果**: ✅ 通过
@@ -74,11 +107,11 @@
### TC-LM-008: 非4倍数提示 ### TC-LM-008: 非4倍数提示
- **结果**: ✅ 通过 - **结果**: ✅ 通过
- **详情**: 验证通过, Pin数=6 (非4倍数) - **详情**: 验证通过, Pin数=14 (非4倍数)
### TC-LM-009: 布局计算正确性 ### TC-LM-009: 布局计算正确性
- **结果**: ✅ 通过 - **结果**: ✅ 通过
- **详情**: 布局计算正确: left=3, bottom=2, right=1, top=2, 逆时针顺序正确 - **详情**: 布局计算正确: left=3, bottom=3, right=3, top=3, 逆时针顺序正确
### TC-LM-010: 模板文件检测(无模板) ### TC-LM-010: 模板文件检测(无模板)
- **结果**: ✅ 通过 - **结果**: ✅ 通过
@@ -94,11 +127,11 @@
### TC-LM-012: 输出文件正确性 ### TC-LM-012: 输出文件正确性
- **结果**: ✅ 通过 - **结果**: ✅ 通过
- **详情**: 输出文件验证通过: A1=QFP-8, 包含Pin1-Pin8 - **详情**: 输出文件验证通过: A1=QFP-12, 包含Pin1-Pin12
### TC-LM-013: 端到端Roundtrip (MAP→List→MAP) ### TC-LM-013: 端到端Roundtrip (MAP→List→MAP)
- **结果**: ✅ 通过 - **结果**: ✅ 通过
- **详情**: Roundtrip成功: PinList(8) → PinMAP(3×3) → PinList(8), 序号一致 - **详情**: Roundtrip成功: PinList(12) → PinMAP(3×3) → PinList(12), 序号一致
### TC-LM-014: 输出路径生成 ### TC-LM-014: 输出路径生成
- **结果**: ✅ 通过 - **结果**: ✅ 通过
@@ -112,6 +145,64 @@
- **结果**: ✅ 通过 - **结果**: ✅ 通过
- **详情**: 正确报错: A1 单元格为空,无法获取封装信息 - **详情**: 正确报错: A1 单元格为空,无法获取封装信息
## Part 3: v1.5 模板/样式集成测试
### TC-v1.5-001: MAP->List 加载 BallList 模板
- **结果**: ✅ 通过
- **详情**: 模板加载成功: fonts=2, borders=2, width_A=25.0
### TC-v1.5-002: MAP->List 无模板降级
- **结果**: ✅ 通过
- **详情**: 无模板文件时优雅返回 None
### TC-v1.5-003: List->MAP 加载 BallMAP 模板
- **结果**: ✅ 通过
- **详情**: 模板加载成功: fonts=2, borders=2, row_height=25.0
### TC-v1.5-004: List->MAP 无模板降级
- **结果**: ✅ 通过
- **详情**: 无模板文件时优雅返回 None
### TC-v1.5-005: 两个方向独立使用各自模板
- **结果**: ✅ 通过
- **详情**: 两个模板独立: BL fonts=2, BM fonts=2
### TC-v1.5-006: 模板损坏优雅降级
- **结果**: ✅ 通过
- **详情**: 损坏模板优雅返回 None
### TC-v1.5-007: 模板字体应用到输出文件
- **结果**: ✅ 通过
- **详情**: 输出 styles.xml 包含模板字体(宋体 14pt)
### TC-v1.5-008: 模板列宽应用到输出文件
- **结果**: ✅ 通过
- **详情**: 列宽验证通过: A=25.0, B=18.0
### TC-v1.5-009: 模板行高应用到输出文件
- **结果**: ✅ 通过
- **详情**: 行高验证通过: ht=25
### TC-v1.5-010: 两个方向不同模板各自的格式
- **结果**: ✅ 通过
- **详情**: 两个方向输出字体不同: BL->楷体, BM->宋体
### TC-v1.5-011: 完整往返+模板隔离
- **结果**: ✅ 通过
- **详情**: 往返成功: 12 pins, 楷体->PinList, 宋体->PinMAP
### TC-v1.5-012: 无模板完整流程
- **结果**: ✅ 通过
- **详情**: 无模板完整流程正常
### TC-v1.5-013: 极简模板(只有字体)
- **结果**: ✅ 通过
- **详情**: 极简模板: font=Courier New
### TC-v1.5-014: 列宽扩展
- **结果**: ✅ 通过
- **详情**: 列宽扩展正确: A=15.0, B=12.0, C=10.0, D=8.0, E=8.0
--- ---
## 结论 ## 结论

8
docs/bugs.md Normal file
View File

@@ -0,0 +1,8 @@
# Bug 跟踪表
| Bug ID | 严重程度 | Bug 描述 | 复现步骤 | 期望行为 | 实际行为 | 状态 | 关联功能 |
|--------|---------|---------|---------|---------|---------|------|---------|
| BUG-001 | 中 | run.bat 换行符 + lines 设置不匹配 Windows | 在 Windows 下运行 run.bat | CRLF 换行,仅保留 `mode con cols=80` | Unix LF 换行,包含多余 `lines=50` | 已修复 | F005 |
| BUG-002 | 高 | 周长计算公式错误 | 输入 15×15 网格 + 60 Pin | 验证通过 `(rows+cols)*2=60` | 提示不匹配 `2*rows+2*cols-4=56` | 已修复 | F006 |
| BUG-003 | 中 | 双向转换未读取模板样式 | 使用模板文件进行 MAP↔List 转换 | 读取并应用模板样式 | 使用默认样式 | 已修复 | F007 |
| BUG-004 | 中 | 不支持循环处理流程 | 转换完成后继续操作 | 循环等待下一个文件,输入 Q 返回主菜单 | 处理完直接退出 | 已修复 | F008 |

44
docs/features.md Normal file
View File

@@ -0,0 +1,44 @@
# 功能清单
## 核心功能
| 功能 ID | 功能名称 | 描述 | 输入 | 输出 | 依赖 | 优先级 | 验收标准 | 审批状态 |
|--------|---------|------|------|------|------|--------|---------|---------|
| F001 | PinMAP 解析 | 解析 PinMAP Excel 文件 | Excel 文件 | 解析后的 Pin 数据 | 无 | 1 | 能正确解析 PinMAP 结构 | 已通过 |
| F002 | PinList 生成 | 从 PinMAP 生成 PinList | Pin 数据 | PinList Excel 文件 | F001 | 2 | 能正确生成 PinList | 已通过 |
| F003 | PinList 解析 | 解析 PinList Excel 文件 | Excel 文件 | 解析后的 Pin 数据 | 无 | 1 | 能正确解析 PinList 结构 | 已通过 |
| F004 | PinMAP 生成 | 从 PinList 生成 PinMAP | Pin 数据 | PinMAP Excel 文件 | F003 | 2 | 能正确生成 PinMAP | 已通过 |
## Bug 修复
| 功能 ID | 功能名称 | 描述 | 输入 | 输出 | 依赖 | 优先级 | 验收标准 | 审批状态 |
|--------|---------|------|------|------|------|--------|---------|---------|
| F005 | BAT 脚本修复 | 修复 run.bat 换行符为 CRLF去掉 lines=50 参数 | 无 | 修复后的 run.bat | 无 | 3 | Windows 下正常运行 | 已通过 |
| F006 | 周长公式修复 | 将周长公式从 `2*rows+2*cols-4` 改为 `(rows+cols)*2` | rows, cols | 正确的周长值 | 无 | 1 | 15×15 网格 60Pin 验证通过 | 已通过 |
## 功能增强
| 功能 ID | 功能名称 | 描述 | 输入 | 输出 | 依赖 | 优先级 | 验收标准 | 审批状态 |
|--------|---------|------|------|------|------|--------|---------|---------|
| F007 | 模板读取 | MAP→List 和 List→MAP 双向转换均读取并应用模板样式 | 模板文件 | 带样式的输出文件 | 无 | 2 | 双向转换均应用模板样式 | 已通过 |
| F008 | 循环处理流程 | 处理完不退出,循环等待下一个文件,输入 Q 返回主菜单 | 用户输入 | 循环处理或返回主菜单 | 无 | 2 | 处理完不退出Q 返回主菜单 | 已通过 |
## v1.5.0 新增2026-06-06
| 功能 ID | 功能名称 | 描述 | 输入 | 输出 | 依赖 | 优先级 | 验收标准 | 审批状态 |
|--------|---------|------|------|------|------|--------|---------|---------|
| F009 | MAP→List 使用 balllist 模板 | PinMAP→PinList 转换方向查找并使用 `BallList-Template.xlsx`,不再共用 PinMAP 模板 | BallList-Template.xlsx | 带 balllist 模板样式的 PinList 输出 | 无 | P1 | MAP→List 使用 balllist 模板的样式 | 已完成 |
| F010 | List→MAP 使用 ballmap 模板 | PinList→PinMAP 转换方向查找并使用 `BallMAP-Template.xlsx`,不再共用 PinMAP 模板 | BallMAP-Template.xlsx | 带 ballmap 模板样式的 PinMAP 输出 | 无 | P1 | List→MAP 使用 ballmap 模板的样式 | 已完成 |
| 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/D6Pin5/Pin6 出现在 E5/E4 | 已完成 |
## 优先级排序
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 循环处理流程 — 体验优化

View File

@@ -0,0 +1,621 @@
# PinMAP ↔ PinList 双向转换器 — 修改需求评估 v1.3
> **版本**: v1.3
> **日期**: 2026-05-31
> **评估人**: 脚本架构师 (Script Architect)
> **状态**: 待审批
> **变更**: 4 个 Bug 修复 + 4 个功能增强BUG-001~004, F005~F008
---
## 1. 修改需求总览
| 编号 | 类型 | 标题 | 优先级 | 复杂度 | 关联需求 |
|------|------|------|--------|--------|---------|
| BUG-001 | Bug | run.bat 换行符 + lines 设置不匹配 Windows | 中 | 低 | F005 |
| BUG-002 | Bug | 周长计算公式错误 | **高** | 中 | F006 |
| BUG-003 | Bug | 双向转换未读取模板样式 | 中 | 中 | F007 |
| BUG-004 | Bug | 不支持循环处理流程 | 中 | 中 | F008 |
| F005 | 功能 | BAT 脚本修复 | 3 | 低 | BUG-001 |
| F006 | 功能 | 周长公式修复 | **1** | 中 | BUG-002 |
| F007 | 功能 | 模板读取(双向) | 2 | 中 | BUG-003 |
| F008 | 功能 | 循环处理流程 | 2 | 中 | BUG-004 |
---
## 2. 当前代码状态分析
### 2.1 代码库结构v1.2.0
```
pinmap-to-pinlist/
├── run.bat # ✏️ 需修改BUG-001/F005
├── Code/src/
│ ├── main.py # ✏️ 需修改BUG-003/F007, BUG-004/F008
│ ├── file_selector.py # (不变)
│ ├── validator.py # ✏️ 需修改BUG-002/F006
│ ├── pinlist_validator.py # ✏️ 需修改BUG-002/F006
│ ├── pinmap_layout.py # ✏️ 需修改BUG-002/F006
│ ├── pinlist_parser.py # (不变)
│ ├── pinmap_generator.py # (不变)
│ ├── template_reader.py # (不变)
│ ├── xlsx_writer.py # (不变)
│ ├── models.py # (不变)
│ ├── xls_reader.py # (不变)
│ ├── xlsx_reader.py # (不变)
│ ├── pinlist_generator.py # (不变)
│ └── utils.py # (不变)
└── docs/
├── bugs.md # ✏️ 需更新状态
├── features.md # ✏️ 需更新状态
└── modification-assessment-v1.3.md # 🆕 本文档
```
### 2.2 各 Bug 当前代码状态
#### BUG-001: run.bat 问题
**当前 run.bat 内容**
```bat
@ECHO OFF
chcp 65001 >nul
title PinMAP转PinList -By:LeeQwQ
mode con cols=80 lines=50 ← 问题:含 lines=50
color 0B
cls
cd /d "%~dp0Code\src"
python main.py
echo.
pause
EXIT
```
**问题 1**:文件使用 Unix LF 换行符(`\n`Windows 下应使用 CRLF`\r\n`)。
**问题 2**`mode con cols=80 lines=50` 中的 `lines=50` 是多余的,需求仅保留 `cols=80`
---
#### BUG-002: 周长公式错误
**当前公式**3 处代码):
```
expected_total = 2 * rows + 2 * cols - 4
```
**问题**:对于 15×15 网格 + 60 Pin当前公式计算为 `2*15+2*15-4 = 56`,但用户期望 `(15+15)*2 = 60`
**涉及文件**
1. `Code/src/pinlist_validator.py``validate_pinlist()` 中的周长匹配检查
2. `Code/src/validator.py``validate_pinlist_for_map()` 中的周长匹配检查
3. `Code/src/pinmap_layout.py``calculate_layout()` 中的 `total` 计算和 `LayoutError`
**数学分析**
| 网格 | 当前公式 `2r+2c-4` | 新公式 `(r+c)*2` | 说明 |
|------|-------------------|-----------------|------|
| 4×8 | 20 | 24 | 当前公式少算 4 |
| 15×15 | 56 | 60 | 当前公式少算 4 |
| 2×2 | 4 | 8 | 当前公式少算 4 |
新公式 `(rows+cols)*2` 对所有尺寸均多 4 个 Pin。这意味着布局分配算法也需要相应调整。
**布局分配需同步修改**
当前分配(`pinmap_layout.py`
```
左边: rows 个
下边: cols-1 个
右边: rows-2 个
上边: cols-1 个
总计: 2*rows + 2*cols - 4
```
新分配(`(rows+cols)*2`
```
左边: rows 个
下边: cols 个
右边: rows 个
上边: cols 个
总计: 2*rows + 2*cols
```
这意味着四个角点不再共享,每个角点归属一条边。需重新设计单元格坐标计算逻辑。
---
#### BUG-003: 模板读取路径错误
**当前代码**`main.py``run_list_to_map()`
```python
template_style = read_template_styles(filepath)
```
**问题**`filepath` 是用户选择的 PinList 输入文件路径,而非模板文件路径。模板文件应为根目录下的 `PinMAP-Template.xlsx`
**MAP→List 方向**`run_map_to_list()`
当前代码未调用 `read_template_styles()`PinList 输出直接使用 `write_xlsx()`(无样式)。
**需要修复**
1. List→MAP`read_template_styles(filepath)` 改为读取根目录模板
2. MAP→List增加模板读取和样式应用
---
#### BUG-004: 无循环处理流程
**当前 `main()` 流程**
```python
def main():
show_banner()
# 选择方向 → 执行一次转换 → wait_for_exit() → 程序结束
```
**问题**:转换完成后直接退出,用户需重新运行程序才能处理下一个文件。
**期望流程**
```
启动 → 选择方向 → 处理文件 → [处理完成] → 等待输入下一个文件 / Q 返回主菜单
```
---
## 3. 逐项修改方案
---
### 3.1 BUG-001 / F005: BAT 脚本修复
**修改范围**`run.bat`1 个文件)
**具体修改**
1. **换行符**:确保文件使用 CRLF`\r\n`)换行。写入文件时指定 `newline='\r\n'`
2. **去掉 lines=50**:将 `mode con cols=80 lines=50` 改为 `mode con cols=80`
**修改后 run.bat**
```bat
@ECHO OFF
chcp 65001 >nul
title PinMAP转PinList -By:LeeQwQ
mode con cols=80
color 0B
cls
cd /d "%~dp0Code\src"
python main.py
echo.
pause
EXIT
```
**风险评估**
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| CRLF 写入失败 | 低 | 极低 | Python `open(path, 'w', newline='\r\n')` 保证 |
| 去掉 lines 后窗口行数变回默认 | 低 | 确认 | 默认 300 行缓冲区,足够查看日志 |
**工作量**5 分钟
---
### 3.2 BUG-002 / F006: 周长公式修复
**修改范围**3 个文件
| 文件 | 修改内容 | 修改行数 |
|------|---------|---------|
| `pinlist_validator.py` | 周长匹配公式 + 错误提示文案 | ~5 行 |
| `validator.py` | `validate_pinlist_for_map()` 周长公式 | ~5 行 |
| `pinmap_layout.py` | 边分配计数 + 单元格坐标计算 | ~20 行 |
**具体修改**
#### 3.2.1 `pinlist_validator.py` — `validate_pinlist()`
```python
# 修改前:
expected_total = 2 * rows + 2 * cols - 4
# 修改后:
expected_total = (rows + cols) * 2
```
#### 3.2.2 `validator.py` — `validate_pinlist_for_map()`
```python
# 修改前:
expected_total = 2 * rows + 2 * cols - 4
# 修改后:
expected_total = (rows + cols) * 2
```
#### 3.2.3 `pinmap_layout.py` — `calculate_layout()`
**边分配计数修改**
```python
# 修改前:
left_count = rows
bottom_count = cols - 1
right_count = rows - 2
top_count = cols - 1
# total = 2*rows + 2*cols - 4
# 修改后:
left_count = rows
bottom_count = cols
right_count = rows
top_count = cols
# total = 2*rows + 2*cols
```
**单元格坐标计算修改**
```python
# 修改前(角点共享):
# 左边: (r, 0) r ∈ [1, rows]
# 下边: (rows, c) c ∈ [1, cols-1]
# 右边: (r, cols) r ∈ [rows-1, 2] 逆序
# 上边: (1, c) c ∈ [cols-1, 2] 逆序
# 修改后(角点不共享,每条边独立):
# 左边: (r, 0) r ∈ [1, rows]
# 下边: (rows, c) c ∈ [1, cols]
# 右边: (r, cols) r ∈ [rows, 1] 逆序
# 上边: (1, c) c ∈ [cols, 1] 逆序
```
```python
# 修改后代码:
left_cells = [(r, 0) for r in range(1, rows + 1)]
bottom_cells = [(rows, c) for c in range(1, cols + 1)]
right_cells = [(r, cols) for r in range(rows, 0, -1)]
top_cells = [(1, c) for c in range(cols, 0, -1)]
```
**`get_name_cell()` 函数修改**
```python
# 修改前:
# left: (r, c+1)
# bottom: (r-1, c)
# right: (r, c-1)
# top: (r+1, c)
# 修改后逻辑不变Name 单元格相对于序号单元格的位置不变)
# 但需确保角点单元格 Name 不冲突
```
**风险评估**
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 布局算法修改引入新 Bug | **高** | 中 | 对 4×8、15×15、2×2 等典型尺寸做单元测试 |
| 角点单元格 Name 重叠 | 中 | 中 | 修改 `get_name_cell()` 确保角点 Name 不冲突 |
| 已有用户数据不兼容 | 低 | 低 | 用户需重新输入正确的 PinList |
| 修改 3 个文件不一致 | 中 | 低 | 使用同一公式常量,避免硬编码 |
**工作量**1.5 小时
---
### 3.3 BUG-003 / F007: 模板读取修复
**修改范围**1 个文件(`main.py`
**问题根因**
1. List→MAP`read_template_styles(filepath)` 传入的是输入文件路径,而非模板路径
2. MAP→List完全没有模板读取逻辑
**具体修改**
#### 3.3.1 新增模板路径解析辅助函数
```python
def _find_template_path() -> str | None:
"""查找根目录下的 PinMAP-Template.xlsx。
搜索顺序:
1. 与 run.bat 同级的根目录
2. 当前工作目录
"""
# 尝试从 Code/src 回退到根目录
src_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
template_path = os.path.join(root_dir, "PinMAP-Template.xlsx")
if os.path.exists(template_path):
return template_path
# 回退到当前工作目录
cwd_template = os.path.join(os.getcwd(), "PinMAP-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
```
#### 3.3.2 修改 `run_list_to_map()`
```python
# 修改前:
template_style = read_template_styles(filepath)
# 修改后:
template_path = _find_template_path()
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载模板样式: {template_path}")
else:
print("[WARN] 模板文件存在但解析失败,使用默认样式")
else:
template_style = None
print("[INFO] 未检测到模板文件,使用默认样式")
```
#### 3.3.3 修改 `run_map_to_list()`
```python
# 在写入 PinList 之前,增加模板读取逻辑:
template_path = _find_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载模板样式: {template_path}")
# 写入时:
if template_style is not None:
write_xlsx_with_style(data, output_path, template_style)
else:
write_xlsx(data, output_path)
```
**风险评估**
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 模板路径解析错误 | 中 | 低 | 多路径回退 + 优雅降级 |
| 模板解析失败导致崩溃 | 低 | 低 | `template_reader.py` 已有 try-except 优雅降级 |
| MAP→List 应用模板样式后 PinList 格式不符合用户预期 | 中 | 低 | 模板仅影响样式(字体/边框),不影响数据 |
**工作量**1 小时
---
### 3.4 BUG-004 / F008: 循环处理流程
**修改范围**1 个文件(`main.py`
**具体修改**
`main()` 改造为循环结构:
```python
def main():
show_banner()
while True:
# ── Direction selection ───────────────────────────────
if len(sys.argv) > 1:
# Legacy mode: 直接文件参数 → MAP→List → 循环
direction = 1
filepath = sys.argv[1]
sys.argv = [sys.argv[0]] # 清除 argv下次循环进入交互模式
else:
print("请选择转换方向:")
print(" 1 — PinMAP → PinList")
print(" 2 — PinList → PinMAP")
print(" Q — 退出程序")
print()
choice = input("请输入选项 (1/2/Q): ").strip().upper()
if choice == 'Q':
print("感谢使用,再见!")
return
elif choice == '1':
direction = 1
elif choice == '2':
direction = 2
else:
print("[ERROR] 无效选项,请输入 1、2 或 Q")
continue
filepath = None
# ── Dispatch ──────────────────────────────────────────
if direction == 1:
print()
print("" * 40)
print(" 方向: PinMAP → PinList")
print("" * 40)
print()
run_map_to_list(filepath)
else:
print()
print("" * 40)
print(" 方向: PinList → PinMAP")
print("" * 40)
print()
run_list_to_map(filepath)
# ── 处理完成后循环 ────────────────────────────────────
print()
print("=" * 40)
next_action = input("输入文件名继续处理,或按 Enter 返回主菜单,输入 Q 退出: ").strip()
if next_action.upper() == 'Q':
print("感谢使用,再见!")
return
elif next_action:
# 直接处理指定文件(自动检测方向)
filepath = next_action
# 根据文件内容自动判断方向,或默认 MAP→List
direction = 1
else:
# 返回主菜单(继续 while 循环)
pass
```
**同时需要修改 `run_map_to_list()` 和 `run_list_to_map()`**
将两个函数末尾的 `wait_for_exit()` 替换为 `input("按 Enter 键继续...")`,这样处理完成后用户可以继续操作而不是退出。
```python
# 修改前(两处):
wait_for_exit()
# 修改后:
input("按 Enter 键继续...")
```
**但注意**`wait_for_exit()` 仍保留,用于:
- 致命错误FATAL时的退出
- 用户未选择文件时的退出
- 命令行参数模式下最后一次退出
**风险评估**
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 循环导致内存泄漏(模块重复 import | 低 | 低 | Python import 有缓存,重复 import 无开销 |
| 用户输入 Q 后状态混乱 | 中 | 低 | Q 只在主菜单和处理完成后接受,转换过程中不响应 |
| 命令行参数模式与循环模式冲突 | 中 | 中 | 命令行参数模式执行一次后清除 argv进入交互循环 |
**工作量**1 小时
---
## 4. 修改影响矩阵
| 文件 | BUG-001 | BUG-002 | BUG-003 | BUG-004 | 总改动量 |
|------|---------|---------|---------|---------|---------|
| `run.bat` | ✏️ 换行+CRLF, 去lines | | | | 2 行 |
| `main.py` | | | ✏️ 模板路径 | ✏️ 循环流程 | ~40 行 |
| `pinlist_validator.py` | | ✏️ 公式 | | | ~5 行 |
| `validator.py` | | ✏️ 公式 | | | ~5 行 |
| `pinmap_layout.py` | | ✏️ 公式+布局 | | | ~20 行 |
| **合计** | **1 文件** | **3 文件** | **1 文件** | **1 文件** | **5 文件** |
---
## 5. 优先级排序
| 优先级 | 编号 | 原因 |
|--------|------|------|
| **P0** | BUG-002 / F006 | 核心公式错误,所有 List→MAP 转换均受影响 |
| **P1** | BUG-001 / F005 | 影响 Windows 用户体验,修复简单 |
| **P2** | BUG-003 / F007 | 功能增强,模板样式对输出质量有影响 |
| **P2** | BUG-004 / F008 | 体验优化,批量处理场景需要 |
---
## 6. 工作量估算
| 任务 | 文件 | 预估时间 | 依赖 |
|------|------|---------|------|
| BUG-001/F005 | `run.bat` | 5 分钟 | 无 |
| BUG-002/F006 | `pinlist_validator.py`, `validator.py`, `pinmap_layout.py` | 1.5 小时 | 无 |
| BUG-003/F007 | `main.py` | 1 小时 | 无 |
| BUG-004/F008 | `main.py` | 1 小时 | 无 |
| 文档更新 | `bugs.md`, `features.md` | 10 分钟 | 无 |
**总计预估**:约 3 小时
---
## 7. 推荐开发顺序
```
第1轮独立可并行
BUG-001/F005: BAT 脚本修复5 分钟,任何 Agent
第2轮核心必须最先
BUG-002/F006: 周长公式修复1.5 小时,需理解布局算法)
第3轮独立
BUG-003/F007: 模板读取修复1 小时)
BUG-004/F008: 循环处理流程1 小时)
(两者都修改 main.py建议先后执行避免冲突
第4轮收尾
文档更新bugs.md + features.md
```
---
## 8. 验收标准
### 8.1 BUG-001 / F005 验收
| 验收项 | 方法 | 预期结果 |
|--------|------|---------|
| run.bat 使用 CRLF 换行 | 二进制查看文件 | 每行末尾为 `\r\n` |
| 不含 `lines=` 参数 | 文本搜索 | 无 `lines=` 字符串 |
| 仅含 `mode con cols=80` | 文本搜索 | 仅一行 `mode con cols=80` |
| Windows 下双击运行正常 | 实际运行 | 窗口正常打开,中文显示正确 |
### 8.2 BUG-002 / F006 验收
| 验收项 | 方法 | 预期结果 |
|--------|------|---------|
| 15×15 网格 + 60 Pin 验证通过 | 输入测试 | 无错误提示,转换成功 |
| 4×8 网格 + 24 Pin 验证通过 | 输入测试 | 无错误提示,转换成功 |
| 2×2 网格 + 8 Pin 验证通过 | 输入测试 | 无错误提示,转换成功 |
| 错误 Pin 数量仍报错 | 输入 15×15+56Pin | 提示不匹配 |
| 布局计算正确 | 检查输出文件 | 四条边 Pin 分布正确 |
### 8.3 BUG-003 / F007 验收
| 验收项 | 方法 | 预期结果 |
|--------|------|---------|
| List→MAP 读取模板 | 放置模板文件后转换 | 日志显示"已加载模板样式" |
| MAP→List 读取模板 | 放置模板文件后转换 | 日志显示"已加载模板样式" |
| 无模板时优雅降级 | 不放置模板文件 | 日志显示"未检测到模板文件",使用默认样式 |
| 模板解析失败降级 | 放置损坏的模板文件 | 日志显示"解析失败",使用默认样式 |
| 输出文件样式正确 | 打开输出文件 | 字体、边框、对齐与模板一致 |
### 8.4 BUG-004 / F008 验收
| 验收项 | 方法 | 预期结果 |
|--------|------|---------|
| 处理完不退出 | 完成一次转换 | 显示"按 Enter 键继续"或循环提示 |
| 输入 Q 返回主菜单 | 处理完成后输入 Q | 返回方向选择菜单 |
| 主菜单输入 Q 退出 | 主菜单输入 Q | 程序退出 |
| 连续处理多个文件 | 连续选择文件 | 可连续处理,无需重新运行 |
| 命令行参数模式 | `run.bat input.xls` | 处理完成后进入循环 |
---
## 9. 风险评估汇总
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 周长公式修改导致已有布局算法不一致 | **高** | 中 | 同步修改 validator + layout确保公式统一 |
| 角点单元格 Name 冲突 | 中 | 中 | 修改 `get_name_cell()` 确保不重叠 |
| main.py 两处修改冲突 | 中 | 中 | 先完成 BUG-003再完成 BUG-004避免同时修改 |
| 模板路径在命令行模式下解析错误 | 低 | 低 | 使用 `__file__` 绝对路径而非 cwd |
| 循环流程中模块重复 import 性能 | 低 | 极低 | Python 有 import 缓存 |
---
## 10. 总结
| 项目 | 内容 |
|------|------|
| 修改文件数 | 5 个run.bat, main.py, pinlist_validator.py, validator.py, pinmap_layout.py |
| 新增文件数 | 0 |
| 影响核心模块 | 是pinmap_layout.py 布局算法) |
| 技术难度 | 中(周长公式 + 布局算法需同步修改) |
| 预估工作量 | ~3 小时 |
| 推荐 Agent | Python 编码 Agent1-2 个) |
| 风险等级 | 中(公式修改需仔细验证) |
**结论**
1. BUG-002 为最高优先级,影响所有 List→MAP 转换的正确性
2. BUG-001 修复最简单,可快速完成
3. BUG-003 和 BUG-004 都修改 `main.py`,需先后执行避免冲突
4. 所有修改均使用 Python 标准库,无新增依赖
5. 建议修改完成后运行完整测试套件验证
---
*文档结束 — 请审批后进入编码阶段*

View File

@@ -0,0 +1,880 @@
# PinMAP ↔ PinList 双向转换器 — v1.5.0 修改需求评估
> **版本**: v1.5.0
> **日期**: 2026-06-06
> **评估人**: 脚本架构师 (Script Architect)
> **状态**: 待审批
> **变更**: 1 个 P0 Bug 修复 + 3 个 P1 模板分离功能F009~F012
---
## 1. 修改需求总览
| 编号 | 类型 | 标题 | 优先级 | 复杂度 | 依赖 |
|------|------|------|--------|--------|------|
| F012 | Bug修复 | 修复 PinMAP 生成中上/下边 PinName 位置 | **P0** | 低 | 无 |
| F009 | 功能 | MAP→List 使用 balllist 模板 | P1 | 中 | 无 |
| F010 | 功能 | List→MAP 使用 ballmap 模板 | P1 | 中 | 无 |
| F011 | 功能 | 模板格式提取式应用 | P1 | 中 | F009, F010 |
---
## 2. 当前代码状态分析
### 2.1 代码库结构v1.4.x 基线)
```
pinmap-to-pinlist/
├── run.bat
├── Code/
│ ├── src/
│ │ ├── main.py # ✏️ 需修改 (F009/F010/F011)
│ │ ├── file_selector.py # (不变)
│ │ ├── models.py # (不变)
│ │ ├── pinlist_generator.py # (不变)
│ │ ├── pinlist_parser.py # (不变)
│ │ ├── pinlist_validator.py # (不变)
│ │ ├── pinmap_generator.py # (不变/需确认 F012 影响)
│ │ ├── pinmap_layout.py # ✏️ 可能需修改 (F012)
│ │ ├── pinmap_parser.py # (不变)
│ │ ├── template_reader.py # ✏️ 可能需修改 (F011)
│ │ ├── utils.py # (不变)
│ │ ├── validator.py # (不变)
│ │ ├── xls_reader.py # (不变)
│ │ ├── xlsx_reader.py # (不变)
│ │ ├── xlsx_writer.py # (不变/需确认 F011 影响)
│ │ └── test_pinmap.py # ✏️ 需更新 (F012)
│ └── docs/
│ ├── architecture-design.md
│ ├── modification-assessment.md
│ ├── QUICKSTART.md
│ ├── README.md
│ ├── RELEASE.md
│ └── team.md
├── docs/
│ ├── bugs.md
│ ├── features.md
│ ├── modification-assessment-v1.3.md
│ ├── requirements.md
│ └── tasks.md
├── Test/
│ ├── fixtures/
│ │ ├── sample_4x4.xlsx
│ │ ├── sample_rect.xlsx
│ │ ├── error_*.xlsx
│ │ └── warning_missing.xlsx
│ ├── run_tests.py
│ └── test_report.md
└── Releases/
```
### 2.2 现有模板机制
当前代码v1.4.x使用**单一模板文件** `PinMAP-Template.xlsx`(位于项目根目录):
```python
# main.py → _find_template_path()
# 查找根目录下的 PinMAP-Template.xlsx
def _find_template_path() -> str | None:
src_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(src_dir))
template_path = os.path.join(root_dir, "PinMAP-Template.xlsx")
...
```
**当前行为**
- `run_map_to_list()`MAP→List`run_list_to_map()`List→MAP都查找同一个 `PinMAP-Template.xlsx`
- 两个方向共用同一个模板文件
- v1.5.0 需求:将两个方向的模板分离
### 2.3 各模块当前实现要点
#### `main.py` — 入口流程编排
```python
# 模板查找:单一模板
def _find_template_path() -> str | None:
"""查找 PinMAP-Template.xlsx"""
# run_map_to_list(): MAP→List 流程
# 使用 _find_template_path() 读取模板 → 应用到 PinList 输出
# run_list_to_map(): List→MAP 流程
# 使用 _find_template_path() 读取模板 → 应用到 PinMAP 输出
```
**关键观察**:两个方向都通过 `_find_template_path()` 查找同一模板。v1.5.0 需要分离。
#### `pinmap_layout.py` — PinMAP 布局计算 + Name 坐标
```python
# 单元格坐标体系0-based
# 左边: 序号在 (r, 0), Name 在 (r, c+1) = (r, 1)
# 下边: 序号在 (rows, c), Name 在 (r-1, c) = (rows-1, c)
# 右边: 序号在 (r, cols), Name 在 (r, c-1) = (r, cols-1)
# 上边: 序号在 (1, c), Name 在 (r+1, c) = (2, c)
def get_name_cell(num_cell, edge_name):
if edge_name == "left": return (r, c+1) # Name 右侧
elif edge_name == "bottom": return (r-1, c) # Name 上方
elif edge_name == "right": return (r, c-1) # Name 左侧
elif edge_name == "top": return (r+1, c) # Name 下方
```
**关于 F012 的关键分析**(详见第 3.1 节):
| 边 | 当前 Name 位置 | 相对序号 | F012 声称"当前" | F012 声称"应改为" |
|----|---------------|---------|----------------|-----------------|
| bottom | `(r-1, c)` = max_row-1 | Name 在序号**上方** | min_row+1 | max_row-1 |
| top | `(r+1, c)` = min_row+1 | Name 在序号**下方** | max_row-1 | min_row+1 |
**结论**:当前代码实际行为已经符合 F012 的"应改为"目标bottom Name 在 max_row-1top Name 在 min_row+1。F012 描述中的"当前"状态可能指向旧版本代码或使用不同坐标基准的描述。详见 3.1 节分析。
#### `pinmap_generator.py` — PinMAP 单元格数据构建
```python
def generate_pinmap(entries, rows, cols, package_info,
template_style=None, output_path=None):
# 1. 计算布局 → layout (dict[str, EdgePins])
# 2. 先写入 PinName 单元格
# 3. 再写入序号单元格(后可覆盖同名单元格)
# 4. 写入文件(模板样式或默认样式)
```
#### `template_reader.py` — 模板样式提取
```python
# 关键能力:
# - 解析 xl/styles.xml → fonts, fills, borders, cellXfs
# - 解析 sheet1.xml → column_widths, row_heights
# - 优雅降级:模板不存在/解析失败 → 返回 None
@dataclass
class TemplateStyle:
fonts: list[FontStyle]
borders: list[BorderStyle]
fills: list[FillStyle]
cell_xfs: list[dict] # xf index → {fontId, borderId, fillId, alignment}
column_widths: dict[int, float]
row_heights: dict[int, float]
```
#### `xlsx_writer.py` — XLSX 输出(含样式)
```python
# StyledXLSXWriter — 生成含 styles.xml 的 xlsx
# _styles_xml(): 读取模板字体/填充/边框 → 构建 styles.xml
# 当前实现:使用硬编码样式(模板字体仅作参考,主体样式内置)
# _sheet_xml(): 应用列宽/行高 + 单元格样式索引
# style_idx: 0=default, 1=centered+border, 2=bold(A1), 3=fill
```
**关键观察**`StyledXLSXWriter._styles_xml()` 目前是**硬编码样式**(内置字体、边框定义),仅从模板读取字体名称/大小作为参考。列宽和行高从模板的**实际行列**读取,但会按输出数据的实际行列扩展。
---
## 3. 逐项修改方案
---
### 3.1 F012: 修复 PinMAP 生成中上/下边 PinName 位置
#### 3.1.1 需求分析
**F012 描述原文**
> 修复 PinMAP 生成中上/下边 PinName 位置
> - 当前:下边 Name 在 min_row+1上边 Name 在 max_row-1
> - 应改为:下边 Name 在 max_row-1上边 Name 在 min_row+1
**4×4 参考示例**
```
A4:1, A5:2, B4:Pin1, B5:Pin2 (上边)
C7:3, D7:4, C6:Pin3, D6:Pin4 (右边)
F5:5, F4:6, E5:Pin5, E4:Pin6 (下边)
D2:7, C2:8, D3:Pin7, C3:Pin8 (左边)
```
#### 3.1.2 代码现状追踪
**当前 `get_name_cell` 实现**
```python
def get_name_cell(num_cell, edge_name):
r, c = num_cell
if edge_name == "left": return (r, c+1) # Name 在序号右侧
elif edge_name == "bottom": return (r-1, c) # Name 在序号上方 (max_row-1)
elif edge_name == "right": return (r, c-1) # Name 在序号左侧
elif edge_name == "top": return (r+1, c) # Name 在序号下方 (min_row+1)
```
**当前 `calculate_layout` 单元格坐标**
```python
# 下边:序号在 (rows, c),即 max_row
# → get_name_cell 返回 (rows-1, c) = max_row-1 ← 上方
#
# 上边:序号在 (1, c),即 min_row
# → get_name_cell 返回 (2, c) = min_row+1 ← 下方
```
#### 3.1.3 关键发现:代码可能已经正确
将 F012 的"应改为"目标与当前代码对比:
| 边 | F012"应改为"目标 | 当前代码实际位置 | 是否一致 |
|----|-----------------|-----------------|---------|
| 下边 (bottom) | max_row-1 | `r-1` = rows-1 = max_row-1 | ✅ 一致 |
| 上边 (top) | min_row+1 | `r+1` = 1+1 = min_row+1 | ✅ 一致 |
**`test_pinmap.py` 中的 4×4 测试数据验证**
```python
# Test data (0-based):
# bottom edge: numbers at (6,2)=3, (6,3)=4; names at (5,2)=Pin3, (5,3)=Pin4
# → max_row=6, names at row 5 = max_row-1 ✓
# top edge: numbers at (1,3)=7, (1,2)=8; names at (2,3)=Pin7, (2,2)=Pin8
# → min_row=1, names at row 2 = min_row+1 ✓
```
**该测试在当前代码中已通过**,表明当前 `get_name_cell` 返回值与测试数据一致。
#### 3.1.4 可能的问题场景
如果确实存在问题,以下场景需排查:
1. **PinMAP 解析方向**`pinmap_parser.py` 解析 PinMAP 时,底边 Name 读自 `max_row-1`,顶边 Name 读自 `min_row+1`。这与生成方向一致。但如果解析时使用了错误的位置假设,则来回转换会导致 Name 错位。
2. **边名称语义混淆**F012 描述中的 `(上边)` `(右边)` `(下边)` `(左边)` 标签可能使用了不同的约定(例如,该标签可能对应的是"Name 边的位置"而非"序号所在边")。
3. **网格区域偏移**:如果 `A1` 被保留为封装信息,网格区域从第 2 行开始1-based → 0-based row=1`min_row``max_row` 的起始值需要重新校准。
#### 3.1.5 修改方案
**方案 A确认代码已正确仅添加回归测试**
如果代码已正确,则:
1. 不修改 `pinmap_layout.py`
2.`test_pinmap.py` 中增加显式的上/下边 Name 位置验证测试
3. 更新测试覆盖率
**方案 B按 F012 描述修改(如果确定存在 Bug**
如果确实需要交换,修改 `get_name_cell`
```python
# 修改前:
elif edge_name == "bottom": return (r-1, c) # Name 在序号上方
elif edge_name == "top": return (r+1, c) # Name 在序号下方
# 修改后(交换 bottom/top 的 Name 位置):
elif edge_name == "bottom": return (r+1, c) # Name 移到序号下方
elif edge_name == "top": return (r-1, c) # Name 移到序号上方
```
**但此修改会导致现有 `test_4x4_parse` 测试失败**,因为测试数据中 bottom Name 在 `max_row-1`
**方案 C仅修改 `pinmap_parser.py` 的 Name 读取位置**
如果问题是解析方向MAP→List使用的位置不正确
修改 `pinmap_parser.py` 中 bottom/top 的 Name 查找行号。
#### 3.1.6 建议
**强烈建议在实施前与需求方确认具体的 Bug 表现**。基于代码分析:
| 事实 | 结论 |
|------|------|
| `get_name_cell` 中 bottom Name 在 max_row-1 | 符合 F012 的"应改为" |
| `get_name_cell` 中 top Name 在 min_row+1 | 符合 F012 的"应改为" |
| 4×4 测试已通过 | 代码内部一致 |
| `pinmap_parser.py` 使用相同约定 | 读写一致 |
| F012 描述中的"当前"与代码不符 | 可能存在描述误差 |
**实施步骤**(按优先级):
1. **先确认问题**:使用实际的 4×4 PinMAP 输入文件,运行完整的 PinList→PinMAP 转换,检查输出
2. **定位差异**:对比输出与预期,定位是 `get_name_cell` 还是 `pinmap_parser` 的差异
3. **修改代码**:根据确认结果修改对应的函数
4. **更新测试**:同步更新 `test_pinmap.py` 中的测试数据和断言
**影响范围**
| 文件 | 修改内容 | 可能性 |
|------|---------|--------|
| `pinmap_layout.py` | 修改 `get_name_cell` 的 bottom/top 逻辑 | 低(可能需要确认) |
| `test_pinmap.py` | 更新测试数据/断言 | 中 |
| `pinmap_parser.py` | 修改 Name 读取行号 | 低 |
**风险评估**
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 修改后破坏往返转换一致性 | **高** | 中 | 运行完整的 MAP→List→MAP 往返测试 |
| 修改后破坏现有用户输出 | 中 | 低 | 确认用户实际使用场景 |
| 与需求方沟通不足导致反复修改 | 中 | 中 | **先确认再改** |
**工作量**0.51 小时(含需求确认)
---
### 3.2 F009: MAP→List 使用 balllist 模板
#### 3.2.1 需求
> PinMAP→PinList 转换方向查找并使用 `BallList-Template.xlsx`,不再共用 PinMAP 模板
**变更前**`run_map_to_list()` 使用 `_find_template_path()``PinMAP-Template.xlsx`
**变更后**`run_map_to_list()` 查找并使用 `BallList-Template.xlsx`
#### 3.2.2 影响范围
仅在 `main.py` 中修改模板查找逻辑,核心转换代码不受影响。
**涉及文件**
| 文件 | 修改级别 | 说明 |
|------|---------|------|
| `main.py` | ✏️ 修改 | 新增 `_find_balllist_template_path()` 函数;修改 `run_map_to_list()` 中的模板查找调用 |
**不涉及**`pinmap_parser.py`, `pinlist_generator.py`, `xlsx_writer.py`, `template_reader.py`(这些模块只接收 `TemplateStyle` 对象,不关心模板文件名)
#### 3.2.3 具体修改方案
**3.2.3.1 新增 `_find_balllist_template_path()` 函数**
```python
def _find_balllist_template_path() -> str | None:
"""查找根目录下的 BallList-Template.xlsx。
搜索顺序:
1. 与 run.bat 同级的根目录
2. 当前工作目录
"""
src_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
template_path = os.path.join(root_dir, "BallList-Template.xlsx")
if os.path.exists(template_path):
return template_path
cwd_template = os.path.join(os.getcwd(), "BallList-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
```
**3.2.3.2 修改 `run_map_to_list()` 中的模板查找**
```python
# 修改前:
template_path = _find_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
# 修改后:
template_path = _find_balllist_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载 BallList 模板样式: {template_path}")
else:
print("[WARN] BallList 模板文件存在但解析失败,使用默认样式")
else:
print("[INFO] 未检测到 BallList-Template.xlsx使用默认样式")
```
**3.2.3.3 输出路径保持不变**
PinList 输出路径仍为 `{input_base}_PinList.xlsx`,不受模板变更影响。
#### 3.2.4 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 用户未放置 BallList-Template.xlsx | 低 | 高 | 模板不存在 → 优雅降级使用默认样式(已实现) |
| 新旧模板共存产生混淆 | 低 | 低 | 日志明确输出使用的模板文件名 |
| BallList-Template.xlsx 解析失败 | 低 | 低 | template_reader 已有 try-except 降级 |
**工作量**15 分钟
---
### 3.3 F010: List→MAP 使用 ballmap 模板
#### 3.3.1 需求
> PinList→PinMAP 转换方向查找并使用 `BallMAP-Template.xlsx`,不再共用 PinMAP 模板
**变更前**`run_list_to_map()` 使用 `_find_template_path()``PinMAP-Template.xlsx`
**变更后**`run_list_to_map()` 查找并使用 `BallMAP-Template.xlsx`
#### 3.3.2 影响范围
仅在 `main.py` 中修改模板查找逻辑。
**涉及文件**
| 文件 | 修改级别 | 说明 |
|------|---------|------|
| `main.py` | ✏️ 修改 | 新增 `_find_ballmap_template_path()` 函数;修改 `run_list_to_map()` 中的模板查找调用 |
#### 3.3.3 具体修改方案
**3.3.3.1 新增 `_find_ballmap_template_path()` 函数**
```python
def _find_ballmap_template_path() -> str | None:
"""查找根目录下的 BallMAP-Template.xlsx。
搜索顺序:
1. 与 run.bat 同级的根目录
2. 当前工作目录
"""
src_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
template_path = os.path.join(root_dir, "BallMAP-Template.xlsx")
if os.path.exists(template_path):
return template_path
cwd_template = os.path.join(os.getcwd(), "BallMAP-Template.xlsx")
if os.path.exists(cwd_template):
return cwd_template
return None
```
**3.3.3.2 修改 `run_list_to_map()` 中的模板查找**
```python
# 修改前:
template_path = _find_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
...
# 修改后:
template_path = _find_ballmap_template_path()
template_style = None
if template_path:
template_style = read_template_styles(template_path)
if template_style:
print(f"[INFO] 已加载 BallMAP 模板样式: {template_path}")
else:
print("[WARN] BallMAP 模板文件存在但解析失败,使用默认样式")
else:
print("[INFO] 未检测到 BallMAP-Template.xlsx使用默认样式")
```
#### 3.3.4 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 用户未放置 BallMAP-Template.xlsx | 低 | 高 | 优雅降级使用默认样式 |
| 新旧模板共存产生混淆 | 低 | 低 | 日志明确输出使用的模板文件名 |
**工作量**15 分钟
---
### 3.4 F011: 模板格式提取式应用
#### 3.4.1 需求
> 从模板仅提取格式信息(字体、边框、对齐、列宽、行高),输出文件行列数由实际 Pin 数量决定,不复制模板行列结构。
**依赖**F009BallList-Template.xlsx+ F010BallMAP-Template.xlsx
#### 3.4.2 当前实现分析
当前模板应用于 `StyledXLSXWriter``xlsx_writer.py`),其行为:
**`_sheet_xml()` 中的列宽/行高处理**
```python
# 当前逻辑:
# 1. 遍历模板 style.column_widths按最大 col 扩展
# 2. 对于模板有定义的列使用模板宽度,否则默认 8.0
# 3. 遍历 style.row_heights有定义的行使用模板行高
col_widths_xml = ''
if self._style and self._style.column_widths:
max_width_col = max(self._style.column_widths.keys())
max_width_col = max(max_width_col, max_col)
for c in range(max_width_col + 1):
width = self._style.column_widths.get(c, 8.0)
...
```
**`_styles_xml()` 中的样式处理**
```python
# 当前逻辑:硬编码 4 种 xf 样式
# - xf 0: default
# - xf 1: centered + thin borderpin cells
# - xf 2: bold + centeredA1
# - xf 3: centered + border + light fillheader
#
# 仅从模板提取:
# - font[0] 的 name/size/color用于 xf 0, 1, 3
# - font[0] 被复制为 font[1]bold 版,用于 xf 2 = A1
```
#### 3.4.3 关键分析:当前实现是否已满足 F011 要求
| F011 要求 | 当前实现 | 是否满足 |
|-----------|---------|---------|
| 仅提取格式信息 | ✅ 模板仅用于样式 | ✅ 已满足 |
| 字体 | ✅ 从模板 font[0] 提取 | ✅ 已满足 |
| 边框 | ❌ 硬编码 thin border | ⚠️ 部分满足 |
| 对齐 | ✅ 硬编码 center/center | ⚠️ 部分满足 |
| 列宽 | ✅ 从模板提取 | ✅ 已满足 |
| 行高 | ✅ 从模板提取(有则不覆盖) | ✅ 已满足 |
| 输出行列由实际 Pin 决定 | ✅ 不复制模板行列结构 | ✅ 已满足 |
| 不复制模板行列结构 | ✅ dim 由数据决定 | ✅ 已满足 |
**核心差距**:当前 `_styles_xml()` 中边框和对齐是硬编码的,未从模板的 `cellXfs` 中提取。F011 要求**完全**从模板提取格式。
#### 3.4.4 修改方案
**目标**:将 `_styles_xml()` 改造为从模板的 `cellXfs``fonts``borders``fills` 中提取并原样输出样式,而非硬编码。
**涉及文件**
| 文件 | 修改级别 | 说明 |
|------|---------|------|
| `template_reader.py` | ✏️ 修改 | 增强样式提取能力:提取 cellXfs 的完整信息(含 numFmtId, xfId 等) |
| `xlsx_writer.py` | ✏️ 修改 | 重写 `_styles_xml()` 使用模板原始样式定义 |
**3.4.4.1 增强 `template_reader.py`**
当前 `TemplateReader._parse_styles_xml()` 已能提取:
- ✅ fontsFontStyle: name, size, bold, italic, color
- ✅ fillsFillStyle: pattern_type, fg_color
- ✅ bordersBorderStyle: top, bottom, left, right, color
- ✅ cell_xfslist[dict]: numFmtId, fontId, fillId, borderId, alignment
**需要增加**
- `cell_xfs` 中的 `xfId` 属性(用于样式继承)
- `cellXfs``count` 属性
当前已提取的信息足以满足 F011。**不需要大幅修改** `template_reader.py`
**3.4.4.2 重写 `xlsx_writer.py` 中的 `_styles_xml()`**
```python
def _styles_xml(self) -> str:
"""Build xl/styles.xml from template styles or defaults."""
s = self._style
# ── Fonts ────────────────────────────────────────────────
if s and s.fonts:
fonts_xml = self._build_fonts_xml(s.fonts)
else:
fonts_xml = self._default_fonts_xml()
# ── Fills ────────────────────────────────────────────────
if s and s.fills:
fills_xml = self._build_fills_xml(s.fills)
else:
fills_xml = self._default_fills_xml()
# ── Borders ──────────────────────────────────────────────
if s and s.borders:
borders_xml = self._build_borders_xml(s.borders)
else:
borders_xml = self._default_borders_xml()
# ── Cell XFs ─────────────────────────────────────────────
if s and s.cell_xfs:
cell_xfs_xml = self._build_cell_xfs_xml(s.cell_xfs)
else:
cell_xfs_xml = self._default_cell_xfs_xml()
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">\n'
+ fonts_xml + '\n'
+ fills_xml + '\n'
+ borders_xml + '\n'
+ cell_xfs_xml + '\n'
+ '</styleSheet>'
)
```
**关键设计决策**
| 决策点 | 方案 | 理由 |
|--------|------|------|
| 是否完全复制模板 cellXfs | ✅ 是 | 模板定义什么样式就用什么样式 |
| 是否需要额外样式(如 A1 bold | ⚠️ 在模板中应有对应的 xf | 鼓励用户在模板中定义 A1 的样式 |
| 无模板时的默认样式 | 保持现有硬编码 | 向后兼容 |
| 列宽/行高处理 | 保持现有逻辑 | 已满足 F011 |
**3.4.4.3 cellXfs 映射策略**
模板的 cellXfs 索引需要映射到输出单元格:
- 模板中可能有多种 xf如 xf 0=default, xf 1=边框, xf 2=粗体...
- 输出时需决定每个单元格使用哪个 xf
**推荐策略**
1. 分析模板 cellXfs找到最合适的 "pin cell" 样式(通常是带边框+居中的 xf
2. A1 使用模板中带 bold 的 xf如果存在否则用 pin cell 样式
3. 普通 pin cell 使用模板中的 "pin cell" xf
4. 如果模板只有 1 个 xfdefault使用默认样式作为补充
**简化方案**(推荐首版):
- 保留现有的 4 个样式槽位xf 0~3
- 但从模板读取实际的字体、边框、填充定义填充到对应槽位
- 而非硬编码 "thin" 边框和 "center" 对齐
**实现优先级**
1. **字体信息**:已从模板读取 ✅
2. **边框样式**:从模板 borders 读取 → 替换硬编码的 "thin"
3. **填充样式**:从模板 fills 读取 → 替换硬编码的 "FFF0F0F0"
4. **对齐方式**:从模板 alignment 读取 → 替换硬编码的 "center"
5. **列宽/行高**:已从模板读取 ✅
#### 3.4.5 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 模板 cellXfs 结构与输出不匹配 | 中 | 中 | 提供合理的 fallback至少 xf 0 和 xf 1 |
| 模板无边框定义导致输出无网格线 | 中 | 低 | 模板无边框时保留默认 thin border |
| 样式重写引入 OOXML 兼容性问题 | 低 | 低 | 使用 Python 标准库 XML 构建,避免硬编码 namespace 错误 |
| 已有用户无模板场景不受影响 | 低 | - | 无模板时完全回退到现有硬编码样式 |
**工作量**23 小时
---
## 4. 模板文件命名规范建议
### 4.1 v1.5.0 新模板命名
| 用途 | 文件名 | 位置 | 格式 |
|------|--------|------|------|
| MAP→List 输出样式 | `BallList-Template.xlsx` | 项目根目录(与 run.bat 同级) | `.xlsx` |
| List→MAP 输出样式 | `BallMAP-Template.xlsx` | 项目根目录(与 run.bat 同级) | `.xlsx` |
### 4.2 向后兼容
| 旧文件名 | 状态 | 说明 |
|---------|------|------|
| `PinMAP-Template.xlsx` | ⚠️ 废弃(不再自动查找) | v1.5.0 后不再被代码引用 |
### 4.3 命名规范说明
```
模板文件名格式:
{输出格式简称}-Template.xlsx
其中:
- BallList → PinMAP→PinList 转换的输出格式PinList
- BallMAP → PinList→PinMAP 转换的输出格式PinMAP
命名原则:
1. 以输出格式命名,而非输入格式
2. 使用 "Ball" 前缀避免与 "Pin" 名混淆
3. 保持 PascalCase 风格
4. 使用 "-Template" 后缀明确表示模板文件
```
### 4.4 搜索路径优先级
```
1. {project_root}/BallList-Template.xlsx (与 run.bat 同级)
2. {cwd}/BallList-Template.xlsx (当前工作目录)
3. 无 → 使用默认样式(硬编码 Calibri 11pt + thin border + center align
```
---
## 5. 修改影响矩阵
| 文件 | F012 | F009 | F010 | F011 | 总改动量 | 备注 |
|------|------|------|------|------|---------|------|
| `main.py` | — | ✏️ 模板查找 | ✏️ 模板查找 | — | ~30行 | 新增2个函数+修改2处调用 |
| `pinmap_layout.py` | ✏️ 可能 | — | — | — | ~5行 | 仅在确认问题后修改 |
| `template_reader.py` | — | — | — | ✏️ 增强 | ~20行 | 增加 xfId 提取 |
| `xlsx_writer.py` | — | — | — | ✏️ 重写 | ~100行 | 重写 _styles_xml() |
| `test_pinmap.py` | ✏️ 更新 | — | — | — | ~10行 | 增加 F012 回归测试 |
| **合计** | **2 文件** | **1 文件** | **1 文件** | **2 文件** | **4-5 文件** | |
---
## 6. 优先级排序
| 优先级 | 编号 | 原因 | 推荐执行顺序 |
|--------|------|------|------------|
| **P0** | F012 | 核心布局 Bug需先确认影响所有 List→MAP 转换 | 第1先确认再决定是否修改 |
| **P1-1** | F009 | MAP→List 模板分离,独立于其他改动 | 第2可与 F010 并行) |
| **P1-2** | F010 | List→MAP 模板分离,独立于其他改动 | 第2可与 F009 并行) |
| **P1-3** | F011 | 依赖 F009+F010 的模板文件存在后才能测试 | 第3 |
---
## 7. 测试要点
### 7.1 F012 测试P0
#### 核心验证
| 测试项 | 输入 | 预期 | 方法 |
|--------|------|------|------|
| 4×4 PinMAP 往返一致性 | PinList(rows=4, cols=4, 8 Pins) → PinMAP → PinList | 往返后 PinList 数据不变 | 自动化 |
| 下边 Name 位置 | rows=4, cols=4 的 PinMAP 输出 | 下边 PinName 在序号行上方一行max_row-1 | 检查输出 xlsx |
| 上边 Name 位置 | rows=4, cols=4 的 PinMAP 输出 | 上边 PinName 在序号行下方一行min_row+1 | 检查输出 xlsx |
| 与 parser 一致性 | 生成的 PinMAP 再用 parser 解析 | 解析出的 Pin 数据和原始一致 | 自动化 |
| 非方形网格 | rows=3, cols=5 的 PinMAP | 上/下边 Name 位置正确 | 检查输出 |
#### 边界条件
| 测试项 | 输入 | 预期 |
|--------|------|------|
| 最小网格 2×2 | PinList(rows=2, cols=2, 8 Pins) | 四条边各 2 Pin角点正确 |
| 大网格 15×15 | PinList(rows=15, cols=15, 60 Pins) | 60 Pin 全部正确分配到四条边 |
| max_row-1 与 min_row+1 重合 | rows=2min_row=1, max_row=2 → max_row-1=1=min_row | Name 不与序号重叠 |
#### 现有测试回归
- `test_4x4_parse()` — 确认不受影响或同步更新
- `test_4x4_validate()` — 确认不受影响
- `test_12pin_square()` — 确认不受影响
### 7.2 F009 测试P1
| 测试项 | 方法 | 预期 |
|--------|------|------|
| BallList-Template.xlsx 存在时加载 | 放置模板 → MAP→List 转换 | 日志显示"已加载 BallList 模板样式" |
| BallList-Template.xlsx 不存在时降级 | 删除模板 → MAP→List 转换 | 日志显示"未检测到 BallList-Template.xlsx",输出使用默认样式 |
| 不加载 PinMAP-Template.xlsx | 仅放置 PinMAP-Template.xlsx → MAP→List 转换 | 不加载旧模板,使用默认样式 |
| 模板样式应用到 PinList 输出 | 放置带自定义字体/边框的模板 → 转换 | 输出 PinList 使用模板字体和边框 |
### 7.3 F010 测试P1
| 测试项 | 方法 | 预期 |
|--------|------|------|
| BallMAP-Template.xlsx 存在时加载 | 放置模板 → List→MAP 转换 | 日志显示"已加载 BallMAP 模板样式" |
| BallMAP-Template.xlsx 不存在时降级 | 删除模板 → List→MAP 转换 | 日志显示"未检测到 BallMAP-Template.xlsx",输出使用默认样式 |
| 不加载 PinMAP-Template.xlsx | 仅放置 PinMAP-Template.xlsx → List→MAP 转换 | 不加载旧模板,使用默认样式 |
### 7.4 F011 测试P1
| 测试项 | 方法 | 预期 |
|--------|------|------|
| 模板字体正确应用 | 模板字体=微软雅黑 12pt → 输出 | 输出文件字体为微软雅黑 12pt |
| 模板边框正确应用 | 模板边框=medium → 输出 | 输出文件边框为 medium 而非 thin |
| 模板填充正确应用 | 模板背景色=黄色 → 输出 | 输出对应单元格有黄色背景 |
| 模板对齐正确应用 | 模板左对齐 → 输出 | 输出文件单元格左对齐 |
| 列宽与模板一致 | 模板 A 列宽=20 → 输出 | 输出对应列宽=20 |
| 行高与模板一致 | 模板行高=25 → 输出 | 输出对应行高=25 |
| 输出行列由实际 Pin 决定 | rows=5, cols=5 → 输出 | 输出为 5×5 网格,不含模板的额外行列 |
| 无模板时使用默认样式 | 无模板文件 → 输出 | 使用 Calibri 11pt + thin border + center align |
### 7.5 集成测试
| 测试项 | 输入 | 预期 |
|--------|------|------|
| 往返转换 (MAP→List→MAP) | sample_4x4.xlsx → PinList → PinMAP | 与原始 PinMAP 一致 |
| 往返转换 (List→MAP→List) | PinList 文件 → PinMAP → PinList | 与原始 PinList 一致 |
| 两个方向使用不同模板 | BallList-Template 和 BallMAP-Template 同时存在 | MAP→List 用 BallListList→MAP 用 BallMAP |
| 一个大模板一个小模板 | BallList 字体=12pt, BallMAP 字体=10pt | 各方向使用各自的字体 |
---
## 8. 开发顺序建议
```
阶段 1: F012 需求确认30 分钟)
├─ 用实际 PinMAP 输入文件测试当前代码的 PinName 位置
├─ 确认 Bug 具体表现
└─ 决定修改方案A/B/C
阶段 2: F009 + F010 模板分离30 分钟,可并行)
├─ main.py: 新增 _find_balllist_template_path()
├─ main.py: 新增 _find_ballmap_template_path()
├─ main.py: 修改 run_map_to_list() 模板查找
├─ main.py: 修改 run_list_to_map() 模板查找
└─ 废弃 _find_template_path()(可选,保留无调用不影响)
阶段 3: F012 修复如需要3060 分钟)
├─ pinmap_layout.py: 修改 get_name_cell()
├─ test_pinmap.py: 更新测试数据和断言
└─ 运行完整测试套件
阶段 4: F011 格式提取23 小时)
├─ template_reader.py: 增加提取项
├─ xlsx_writer.py: 重写 _styles_xml()
├─ xlsx_writer.py: 新增 _build_*_xml() 辅助函数
└─ 模板+无模板场景测试
阶段 5: 收尾30 分钟)
├─ 更新 CHANGELOG.md
├─ 更新 features.md 状态
├─ 更新 VERSION 文件
└─ 运行完整回归测试
```
---
## 9. 风险评估汇总
| 风险 | 影响 | 概率 | 缓解措施 | 相关功能 |
|------|------|------|---------|---------|
| F012 需求描述与代码行为不一致,修改方向错误 | **高** | 中 | **先确认再改**,使用实际文件测试 | F012 |
| F012 修改破坏往返转换一致性 | **高** | 中 | 运行完整 MAP→List→MAP 往返测试 | F012 |
| 模板 cellXfs 结构与输出不兼容 | 中 | 中 | 提供 fallback模板解析失败 = 默认样式 | F011 |
| 用户未放置新模板文件 | 低 | 高 | 优雅降级,日志明确提示缺失的模板名 | F009/F010 |
| 旧 PinMAP-Template.xlsx 被意外加载 | 低 | 低 | 显式删除旧模板查找调用 | F009/F010 |
| F011 样式重写引入 OOXML 兼容性问题 | 低 | 低 | 严格 XML 构建,测试 Excel/WPS 打开 | F011 |
| 两个模板同时存在造成混淆 | 低 | 低 | 日志明确标注每个方向使用的模板文件名 | F009/F010 |
---
## 10. 工作量估算
| 阶段 | 任务 | 预估时间 | 依赖 |
|------|------|---------|------|
| 确认 | F012 需求确认(测试+分析) | 30 分钟 | 无 |
| 开发 | F009 MAP→List 模板分离 | 15 分钟 | 无 |
| 开发 | F010 List→MAP 模板分离 | 15 分钟 | 无 |
| 开发 | F012 修复(如需要) | 3060 分钟 | 需求确认 |
| 开发 | F011 模板格式提取式应用 | 23 小时 | F009+F010 |
| 测试 | 单元测试更新 | 30 分钟 | 所有开发 |
| 测试 | 集成/回归测试 | 30 分钟 | 所有开发 |
| 文档 | CHANGELOG + features.md 更新 | 15 分钟 | 所有开发 |
| **总计** | | **4.56 小时** | |
---
## 11. 总结
| 项目 | 内容 |
|------|------|
| 修改文件数 | 45 个main.py, pinmap_layout.py, template_reader.py, xlsx_writer.py, test_pinmap.py |
| 新增模板文件 | 2 个BallList-Template.xlsx, BallMAP-Template.xlsx |
| 影响核心模块 | 是xlsx_writer.py 样式生成逻辑重写) |
| 技术难度 | 中F011 样式重写需理解 OOXML styles.xml 结构) |
| 预估工作量 | 4.56 小时 |
| 推荐 Agent | Python 编码 Agent |
| 风险等级 | 中F012 需先确认F011 样式重写有复杂度) |
**关键结论**
1. **F012P0**代码当前行为可能已经正确bottom Name 在 max_row-1top Name 在 min_row+1 已与 F012 "应改为"目标一致)。**强烈建议在实施前用实际文件验证**,避免过度修改。如果确认无误,仅需增加回归测试。
2. **F009+F010P1**:改动量小(约 30 行),完全独立,可快速完成。核心是新增两个模板查找函数并替换调用点。
3. **F011P1**:改动量最大(约 120 行),需重写 `xlsx_writer.py``_styles_xml()` 方法。当前代码已部分满足 F011字体、列宽、行高从模板提取主要差距在边框和对齐的硬编码。建议采用"从模板读取实际值填充现有样式槽位"的渐进方案。
4. **向后兼容**:无模板时所有功能完全回退到现有默认样式,不影响已有用户。
5. **推荐开发顺序**:确认 F012 → 实现 F009+F010 → 修复 F012如需要 → 实现 F011 → 测试收尾。
---
*文档结束 — 请审批后进入编码阶段*
##

27
docs/requirements.md Normal file
View File

@@ -0,0 +1,27 @@
# 需求规格说明书
## 项目信息
- 项目名称pinmap-to-pinlist
- 项目 IDPROJ-002
- 项目类型:脚本
- 技术约束Python 脚本,支持 Windows 和 Linux
## 需求描述
PinMAP ↔ PinList 双向转换器,支持 PinMAP→PinList 与 PinList→PinMAP 互转。
## 输入/输出
| 类型 | 描述 |
|-----|------|
| 输入 | PinMAP 或 PinList Excel 文件 |
| 输出 | 转换后的 Excel 文件 |
## 边界条件
- 支持 .xls 和 .xlsx 格式
- Pin 数量必须与网格周长匹配
- 网格尺寸至少为 2x2
## 验收标准
1. 能正确解析 PinMAP 结构
2. 能正确生成 PinList
3. 能正确反向转换
4. 错误处理完善

20
docs/tasks.md Normal file
View File

@@ -0,0 +1,20 @@
# 任务进度表
| 任务 ID | 任务名称 | 负责 Agent | 当前状态 | 任务类型 | 关联功能 | 创建时间 | 完成时间 |
|--------|---------|-----------|---------|---------|---------|---------|---------|
| T001 | 架构设计 | script-architect | 已完成 | 架构设计 | F001-F005 | 2026-05-23 | 2026-05-23 |
| T002 | Python 编码 v1.2 | python-coding-agent | 已完成 | 编码实现 | F001-F005 | 2026-05-23 | 2026-05-26 |
| T003 | BAT 编码 | bat-coding-agent | 已完成 | 编码实现 | F005 | 2026-05-23 | 2026-05-23 |
| T004 | 测试验证 v1.2 | test-qa-agent | 已完成 | 测试验证 | F001-F005 | 2026-05-23 | - |
| T007 | BAT 脚本修复 v1.3 | bat-coding-agent | 已完成 | 编码实现 | F005 | 2026-05-31 | 2026-05-31 |
| T008 | Python 编码 v1.3 | python-coding-agent | 已完成 | 编码实现 | F006-F008 | 2026-05-31 | 2026-05-31 |
| T009 | 测试验证 v1.3 | test-qa-agent | 已完成 | 测试验证 | F005-F008 | 2026-05-31 | 2026-06-06 |
| T010 | 文档生成 v1.3 | doc-gen-agent | 已完成 | 文档编写 | F005-F008 | - | 2026-06-06 |
| T011 | 打包发布 v1.3 | package-release-agent | 已完成 | 打包发布 | F005-F008 | 2026-05-31 | 2026-06-02 | pinmap-to-pinlist-v1.3.14.zip |
| T013 | 打包发布 v1.3.15 修复 | package-release-agent | 已完成 | 打包发布 | - | 2026-06-02 | 2026-06-02 | Release 已创建 + zip 附件已上传 |
| T014 | 架构评估 v1.5 | script-architect | 已完成 | 架构评估 | F009-F012 | 2026-06-06 | 2026-06-06 |
| T015 | 编码实现 v1.5 | python-coding-agent | 已完成 | 编码实现 | F009-F012 | 2026-06-06 | 2026-06-06 |
| T016 | 测试验证 v1.5 | test-architect/test-executor/test-reporter | 已完成 | 测试验证 | F009-F012 | 2026-06-06 | 2026-06-06 |
| T017 | 文档生成 v1.5 | doc-gen-agent | 已完成 | 文档编写 | F009-F012 | 2026-06-06 | 2026-06-06 |
| T018 | 打包发布 v1.5 | package-release-agent | 进行中 | 打包发布 | F009-F012 | 2026-06-06 | - |
| T018 | 打包发布 v1.5 | package-release-agent | 待处理 | 打包发布 | F009-F012 | - | - |

View File

@@ -1,14 +1,11 @@
@ECHO OFF @ECHO OFF
:: 初始化区
chcp 65001 >nul chcp 65001 >nul
title PinMAP转PinList -By:LeeQwQ title PinMAP转PinList -By:LeeQwQ
mode con cols=80 lines=50 mode con cols=80
color 0B color 0B
cls cls
cd /d "%~dp0Code\src" cd /d "%~dp0Code\src"
python main.py python main.py
echo. echo.
pause pause
EXIT EXIT