""" 共享 Excel 工具单元测试(excel_utils) ============================================================= 验证通用 Excel 导入/导出机制(供职级/用户等模块复用): - parse_excel_rows:按列定义读取,必填/解析错误逐行收集,空行跳过 - parse_int / parse_status / format_status:单元格解析与状态展示格式化 - build_export_rows:按列 key 取值并应用 formatter - save_export_file:写工作簿、生成访问 URL(路径通过 monkeypatch 指向临时目录) 不依赖具体业务模块,列定义在用例内自构造。 """ import pytest from openpyxl.reader.excel import load_workbook from openpyxl.workbook import Workbook from src.common.utils import excel_utils # 用例自构造的列定义(结构与 level/user 模块一致) COLUMNS = [ {"title": "名称", "index": 1, "key": "name", "required": True}, {"title": "排序", "index": 2, "key": "sort", "parser": excel_utils.parse_int}, {"title": "状态", "index": 3, "key": "status", "parser": excel_utils.parse_status}, ] class TestParseStatus: """状态单元格解析:中文 / 数字 -> 1/2,非法值报错""" def test_normal_and_disabled(self): assert excel_utils.parse_status("正常") == 1 assert excel_utils.parse_status("禁用") == 2 assert excel_utils.parse_status("1") == 1 assert excel_utils.parse_status(2) == 2 def test_invalid_raises(self): with pytest.raises(ValueError): excel_utils.parse_status("未知") class TestParseInt: """整数单元格解析""" def test_valid(self): assert excel_utils.parse_int("10") == 10 assert excel_utils.parse_int(3) == 3 def test_invalid_raises(self): with pytest.raises(ValueError): excel_utils.parse_int("abc") class TestFormatStatus: """状态展示格式化:1/2 -> 正常/禁用,未知值原样返回""" def test_mapping(self): assert excel_utils.format_status(1) == "正常" assert excel_utils.format_status(2) == "禁用" def test_unknown_passthrough(self): assert excel_utils.format_status(0) == 0 class TestBuildExportRows: """导出行构建:按列 key 取值 + formatter 展示格式化""" def test_formatter_applied_and_key_lookup(self): records = [ {"name": "初级", "status": 1, "sort": 2}, {"name": "禁用级", "status": 2, "sort": 1}, ] columns = [ {"title": "名称", "key": "name"}, {"title": "排序", "key": "sort"}, {"title": "状态", "key": "status", "formatter": excel_utils.format_status}, ] assert excel_utils.build_export_rows(records, columns) == [ ["初级", 2, "正常"], ["禁用级", 1, "禁用"], ] def test_missing_key_becomes_none(self): columns = [{"title": "缺失键", "key": "nope"}] assert excel_utils.build_export_rows([{"name": "x"}], columns) == [[None]] class TestParseExcelRows: """导入行解析:必填校验 / parser 错误 / 空行跳过 / 可选空单元格""" def _make_wb(self, path): wb = Workbook() ws = wb.worksheets[0] ws.append(["名称", "排序", "状态"]) # 表头(start_row=2 跳过) ws.append(["初级", 1, "正常"]) # 合法 ws.append(["坏排序", "abc", "正常"]) # 排序解析失败 ws.append(["缺状态", 2, ""]) # 可选空单元格 -> None ws.append([None, None, None]) # 整行为空 -> 跳过 ws.append(["", 3, "禁用"]) # 必填缺失 wb.save(path) def test_rows_and_errors(self, tmp_path): path = tmp_path / "in.xlsx" self._make_wb(path) rows, errors = excel_utils.parse_excel_rows(str(path), COLUMNS) # 合法行 + 可选空单元格行保留;错误行 / 空行被剔除 assert rows == [ {"name": "初级", "sort": 1, "status": 1}, {"name": "缺状态", "sort": 2, "status": None}, ] # 行级错误带行号与原因 assert any("第3行 排序:" in e for e in errors) assert any("第6行 名称不能为空" in e for e in errors) class TestSaveExportFile: """导出文件落盘与 URL 生成""" def test_writes_file_and_returns_url(self, tmp_path, monkeypatch): monkeypatch.setattr(excel_utils, "FLASK_TEMP_PATH", str(tmp_path)) monkeypatch.setattr(excel_utils, "get_file_url", lambda url: "/static" + url) headers = ["名称", "状态"] rows = [["初级", "正常"], ["高级", "禁用"]] url = excel_utils.save_export_file(headers, rows, filename_prefix="level") # URL 由 get_file_url 拼接(返回约定前缀 + 路径) assert url.startswith("/static") # 文件真实落盘到临时目录,表头与数据一致 import glob saved = glob.glob(str(tmp_path) + "/**/*.xlsx", recursive=True) assert len(saved) == 1 wb = load_workbook(saved[0]) ws = wb.worksheets[0] assert list(ws.iter_rows(values_only=True)) == [ ("名称", "状态"), ("初级", "正常"), ("高级", "禁用"), ] wb.close()