MenuPilot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,490 @@
1
+ """
2
+ Option Specification Template Expander — 选项规格模板展开器。
3
+
4
+ 将包含选项规格定义的主数据表展开为选项模板的明细行。
5
+ 每个主数据行按 5 个维度(糖度/温度/规格/奶底/茶底)展开,
6
+ 每个维度的每个选项值生成一行模板数据。
7
+
8
+ 纯规则引擎,不调用 LLM。
9
+ """
10
+
11
+ import math
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ import pandas as pd
15
+
16
+ # ── 常量 ──────────────────────────────────────────────────────────
17
+
18
+ # 展开维度(按此顺序遍历)
19
+ DIMENSIONS = ["糖度", "温度", "规格", "奶底", "茶底"]
20
+
21
+ # 模板输出列(固定顺序)
22
+ TEMPLATE_COLUMNS = [
23
+ "商品编码", "商品名称", "口味做法组名", "选项名称",
24
+ "最少必选", "最多可选", "推荐项", "默认项",
25
+ ]
26
+
27
+ # 固定常量
28
+ MIN_REQUIRED = 1
29
+ MAX_OPTIONAL = 1
30
+ YES = "是"
31
+ NO = "否"
32
+
33
+
34
+ # ── 内部辅助 ──────────────────────────────────────────────────────
35
+
36
+ def _empty(val: Any) -> bool:
37
+ """判断值是否为空(NaN / None / 空字符串 / 纯空白)。"""
38
+ if val is None:
39
+ return True
40
+ if isinstance(val, float) and math.isnan(val):
41
+ return True
42
+ if isinstance(val, str) and val.strip() == "":
43
+ return True
44
+ return False
45
+
46
+
47
+ def _parse_dimension_list(raw_value: Any) -> List[str]:
48
+ """将分隔的维度列表解析为去重、去空白的值列表。
49
+
50
+ 分隔符:中文分号「;」和 ASCII 分号「;」均支持。
51
+ 同时处理行尾多余的分号和逗号分隔。
52
+
53
+ Args:
54
+ raw_value: 单元格原始值(字符串 / NaN / None)。
55
+
56
+ Returns:
57
+ 去重、去空白后的选项值列表(保持首次出现顺序)。
58
+ """
59
+ if _empty(raw_value):
60
+ return []
61
+
62
+ s = str(raw_value).strip()
63
+ if not s:
64
+ return []
65
+
66
+ # 统一处理:先尝试中文分号,再尝试 ASCII 分号
67
+ # 检测哪种分隔符占主导
68
+ if ";" in s:
69
+ parts = s.split(";")
70
+ elif ";" in s:
71
+ parts = s.split(";")
72
+ elif "," in s:
73
+ parts = s.split(",")
74
+ elif "," in s:
75
+ parts = s.split(",")
76
+ else:
77
+ # 单值
78
+ PLACEHOLDERS_SINGLE = {"-", "—", "无", "/", "\\"}
79
+ cleaned = s.strip()
80
+ if cleaned and cleaned not in PLACEHOLDERS_SINGLE:
81
+ return [cleaned]
82
+ return []
83
+
84
+ # 去空白、去空串、去重、过滤占位符(保持首次出现顺序)
85
+ PLACEHOLDERS = {"-", "—", "无", "/", "\\"}
86
+ seen = set()
87
+ result = []
88
+ for p in parts:
89
+ cleaned = p.strip()
90
+ if cleaned and cleaned not in seen and cleaned not in PLACEHOLDERS:
91
+ seen.add(cleaned)
92
+ result.append(cleaned)
93
+
94
+ return result
95
+
96
+
97
+ def _match_to_yes_no(value: str, target: Any) -> str:
98
+ """判断 value 是否等于 target,返回"是"或"否"。
99
+
100
+ target 为空(NaN/None/空串)时永远返回"否"。
101
+ """
102
+ if _empty(target):
103
+ return NO
104
+ target_str = str(target).strip()
105
+ if not target_str:
106
+ return NO
107
+ return YES if value == target_str else NO
108
+
109
+
110
+ # ── 核心展开函数 ──────────────────────────────────────────────────
111
+
112
+ def expand_master_to_options(
113
+ master_df: pd.DataFrame,
114
+ column_mapping: Optional[dict] = None,
115
+ ) -> pd.DataFrame:
116
+ """将主数据行展开为选项规格模板行。
117
+
118
+ Args:
119
+ master_df: 主数据 DataFrame。
120
+ column_mapping: Agent 传入的列映射(可选),格式:
121
+ {"product_code": "主编码", "product_name": "商品名称",
122
+ "dimensions": {"糖度": "甜度", "温度": "温度"}}
123
+ 用于适配不同列名的模板。未指定的维度使用默认列名。
124
+
125
+ Returns:
126
+ DataFrame with TEMPLATE_COLUMNS,每行一个选项值。
127
+ """
128
+ # 应用列映射
129
+ if column_mapping:
130
+ pc = column_mapping.get("product_code", "主编码")
131
+ pn = column_mapping.get("product_name", "商品名称")
132
+ dim_map = column_mapping.get("dimensions", {})
133
+ else:
134
+ pc, pn = "主编码", "商品名称"
135
+ dim_map = {}
136
+
137
+ # 验证必要列
138
+ missing_fixed = []
139
+ for col in [pc, pn]:
140
+ if col not in master_df.columns:
141
+ missing_fixed.append(col)
142
+ if missing_fixed:
143
+ raise ValueError(
144
+ f"主数据表缺少必要列: {missing_fixed}\n"
145
+ f"当前列名: {list(master_df.columns)}"
146
+ )
147
+
148
+ rows = []
149
+
150
+ # 按主编码去重:同一产品可能有多行(如不同规格变体),
151
+ # 保留第一行(通常含完整的推荐/默认值),跳过后续重复行。
152
+ seen_codes = set()
153
+
154
+ for _, mrow in master_df.iterrows():
155
+ product_code = str(mrow[pc]).strip() if not _empty(mrow.get(pc)) else ""
156
+ if product_code in seen_codes:
157
+ continue
158
+ seen_codes.add(product_code)
159
+
160
+ product_name = str(mrow[pn]).strip() if not _empty(mrow.get(pn)) else ""
161
+
162
+ if not product_code and not product_name:
163
+ print(f"[WARNING] 主数据行缺少主编码和商品名称,已跳过")
164
+ continue
165
+
166
+ for dim in DIMENSIONS:
167
+ actual_dim = dim_map.get(dim, dim)
168
+ actual_rec = dim_map.get(f"推荐{dim}", f"推荐{dim}")
169
+ actual_def = dim_map.get(f"默认{dim}", f"默认{dim}")
170
+
171
+ dim_values = _parse_dimension_list(mrow.get(actual_dim))
172
+ if not dim_values:
173
+ continue
174
+
175
+ recommended_val = mrow.get(actual_rec)
176
+ default_val = mrow.get(actual_def)
177
+
178
+ for value in dim_values:
179
+ rows.append({
180
+ "商品编码": product_code,
181
+ "商品名称": product_name,
182
+ "口味做法组名": dim,
183
+ "选项名称": value,
184
+ "最少必选": MIN_REQUIRED,
185
+ "最多可选": MAX_OPTIONAL,
186
+ "推荐项": _match_to_yes_no(value, recommended_val),
187
+ "默认项": _match_to_yes_no(value, default_val),
188
+ })
189
+
190
+ return pd.DataFrame(rows, columns=TEMPLATE_COLUMNS)
191
+
192
+
193
+ # ── 自测 ──────────────────────────────────────────────────────────
194
+
195
+ if __name__ == "__main__":
196
+ passed = 0
197
+ failed = 0
198
+
199
+ def check(condition, msg):
200
+ global passed, failed
201
+ if condition:
202
+ passed += 1
203
+ print(f" PASS {msg}")
204
+ else:
205
+ failed += 1
206
+ print(f" FAIL {msg}")
207
+
208
+ print("=== Option Expander 自测 ===\n")
209
+
210
+ # ── 1. 基本展开(1行主数据,2个维度) ──
211
+ print("1. 基本展开(1 行主数据,糖度 3 值 + 温度 3 值)")
212
+ master_basic = pd.DataFrame([{
213
+ "主编码": "A001",
214
+ "商品名称": "茉莉绿茶",
215
+ "推荐糖度": "七分糖", "默认糖度": "五分糖",
216
+ "糖度": "七分糖;五分糖;三分糖",
217
+ "推荐温度": "正常冰", "默认温度": "少冰",
218
+ "温度": "正常冰;少冰;去冰",
219
+ "推荐规格": "", "默认规格": "", "规格": "",
220
+ "推荐奶底": "", "默认奶底": "", "奶底": "",
221
+ "推荐茶底": "", "默认茶底": "", "茶底": "",
222
+ }])
223
+ result = expand_master_to_options(master_basic)
224
+ check(len(result) == 6, f"生成 6 行(实际 {len(result)})")
225
+ check(result.iloc[0]["商品编码"] == "A001", "商品编码 = A001")
226
+ check(result.iloc[0]["商品名称"] == "茉莉绿茶", "商品名称 = 茉莉绿茶")
227
+ check(result.iloc[0]["口味做法组名"] == "糖度", "第 1 组 = 糖度")
228
+ check(result.iloc[2]["口味做法组名"] == "糖度", "第 3 组 = 糖度")
229
+ check(result.iloc[3]["口味做法组名"] == "温度", "第 4 组 = 温度")
230
+ print()
231
+
232
+ # ── 2. 中文分号分隔 ──
233
+ print("2. 中文分号分隔")
234
+ values = _parse_dimension_list("七分糖;五分糖;三分糖")
235
+ check(values == ["七分糖", "五分糖", "三分糖"], f"3 个值(实际 {values})")
236
+ print()
237
+
238
+ # ── 3. 空维度跳过 ──
239
+ print("3. 空维度跳过")
240
+ master_empty_dim = pd.DataFrame([{
241
+ "主编码": "A001", "商品名称": "茉莉绿茶",
242
+ "推荐糖度": "七分糖", "默认糖度": "五分糖",
243
+ "糖度": "七分糖;五分糖",
244
+ "推荐温度": "", "默认温度": "", "温度": "",
245
+ "推荐规格": "", "默认规格": "", "规格": "",
246
+ "推荐奶底": "", "默认奶底": "", "奶底": "",
247
+ "推荐茶底": "", "默认茶底": "", "茶底": "",
248
+ }])
249
+ result3 = expand_master_to_options(master_empty_dim)
250
+ check(len(result3) == 2, f"仅糖度 2 行(实际 {len(result3)})")
251
+ check(all(r["口味做法组名"] == "糖度" for _, r in result3.iterrows()), "全部是糖度行")
252
+ print()
253
+
254
+ # ── 4. 单值维度(无分号) ──
255
+ print("4. 单值维度")
256
+ master_single = pd.DataFrame([{
257
+ "主编码": "A001", "商品名称": "茉莉绿茶",
258
+ "推荐糖度": "七分糖", "默认糖度": "七分糖",
259
+ "糖度": "七分糖",
260
+ "推荐温度": "", "默认温度": "", "温度": "",
261
+ "推荐规格": "", "默认规格": "", "规格": "",
262
+ "推荐奶底": "", "默认奶底": "", "奶底": "",
263
+ "推荐茶底": "", "默认茶底": "", "茶底": "",
264
+ }])
265
+ result4 = expand_master_to_options(master_single)
266
+ check(len(result4) == 1, f"1 行(实际 {len(result4)})")
267
+ check(result4.iloc[0]["选项名称"] == "七分糖", "选项名称 = 七分糖")
268
+ print()
269
+
270
+ # ── 5. 推荐项/默认项匹配 ──
271
+ print("5. 推荐项/默认项匹配")
272
+ row_0 = result.iloc[0] # 七分糖:推荐
273
+ row_1 = result.iloc[1] # 五分糖:默认
274
+ row_2 = result.iloc[2] # 三分糖:都不
275
+ check(row_0["推荐项"] == "是" and row_0["默认项"] == "否",
276
+ f"七分糖 → 推荐项=是, 默认项=否 (实际 {row_0['推荐项']}/{row_0['默认项']})")
277
+ check(row_1["推荐项"] == "否" and row_1["默认项"] == "是",
278
+ f"五分糖 → 推荐项=否, 默认项=是 (实际 {row_1['推荐项']}/{row_1['默认项']})")
279
+ check(row_2["推荐项"] == "否" and row_2["默认项"] == "否",
280
+ f"三分糖 → 推荐项=否, 默认项=否 (实际 {row_2['推荐项']}/{row_2['默认项']})")
281
+ print()
282
+
283
+ # ── 6. 全部 5 个维度 ──
284
+ print("6. 全部 5 个维度(每个 2 值)")
285
+ master_all5 = pd.DataFrame([{
286
+ "主编码": "B001", "商品名称": "招牌奶茶",
287
+ "推荐糖度": "全糖", "默认糖度": "七分糖", "糖度": "全糖;七分糖",
288
+ "推荐温度": "正常冰", "默认温度": "少冰", "温度": "正常冰;少冰",
289
+ "推荐规格": "中杯", "默认规格": "中杯", "规格": "中杯;大杯",
290
+ "推荐奶底": "牛奶", "默认奶底": "燕麦奶", "奶底": "牛奶;燕麦奶",
291
+ "推荐茶底": "红茶", "默认茶底": "绿茶", "茶底": "红茶;绿茶",
292
+ }])
293
+ result6 = expand_master_to_options(master_all5)
294
+ check(len(result6) == 10, f"10 行(实际 {len(result6)})")
295
+ # 检查维度分布
296
+ dim_counts = result6["口味做法组名"].value_counts().to_dict()
297
+ check(dim_counts.get("糖度", 0) == 2, f"糖度 2 行(实际 {dim_counts.get('糖度', 0)})")
298
+ check(dim_counts.get("温度", 0) == 2, f"温度 2 行")
299
+ check(dim_counts.get("规格", 0) == 2, f"规格 2 行")
300
+ check(dim_counts.get("奶底", 0) == 2, f"奶底 2 行")
301
+ check(dim_counts.get("茶底", 0) == 2, f"茶底 2 行")
302
+ print()
303
+
304
+ # ── 7. 多行主数据 ──
305
+ print("7. 多行主数据(2 行 × 2 维度 × 2 值)")
306
+ master_multi = pd.DataFrame([
307
+ {"主编码": "A001", "商品名称": "茉莉绿茶",
308
+ "推荐糖度": "七分糖", "默认糖度": "五分糖", "糖度": "七分糖;五分糖",
309
+ "推荐温度": "正常冰", "默认温度": "少冰", "温度": "正常冰;少冰",
310
+ "推荐规格": "", "默认规格": "", "规格": "",
311
+ "推荐奶底": "", "默认奶底": "", "奶底": "",
312
+ "推荐茶底": "", "默认茶底": "", "茶底": ""},
313
+ {"主编码": "A002", "商品名称": "珍珠奶茶",
314
+ "推荐糖度": "全糖", "默认糖度": "标准糖", "糖度": "全糖;标准糖",
315
+ "推荐温度": "热", "默认温度": "热", "温度": "热;正常冰",
316
+ "推荐规格": "", "默认规格": "", "规格": "",
317
+ "推荐奶底": "", "默认奶底": "", "奶底": "",
318
+ "推荐茶底": "", "默认茶底": "", "茶底": ""},
319
+ ])
320
+ result7 = expand_master_to_options(master_multi)
321
+ check(len(result7) == 8, f"8 行(实际 {len(result7)})")
322
+ check(len(result7[result7["商品编码"] == "A001"]) == 4, "A001 有 4 行")
323
+ check(len(result7[result7["商品编码"] == "A002"]) == 4, "A002 有 4 行")
324
+ print()
325
+
326
+ # ── 8. 空主数据 ──
327
+ print("8. 空主数据")
328
+ master_empty = pd.DataFrame(columns=[
329
+ "主编码", "商品名称",
330
+ "推荐糖度", "默认糖度", "糖度",
331
+ "推荐温度", "默认温度", "温度",
332
+ "推荐规格", "默认规格", "规格",
333
+ "推荐奶底", "默认奶底", "奶底",
334
+ "推荐茶底", "默认茶底", "茶底",
335
+ ])
336
+ result8 = expand_master_to_options(master_empty)
337
+ check(len(result8) == 0, f"0 行(实际 {len(result8)})")
338
+ check(list(result8.columns) == TEMPLATE_COLUMNS, "列名正确")
339
+ print()
340
+
341
+ # ── 9. 缺列检测 ──
342
+ print("9. 缺列检测")
343
+ master_no_code = pd.DataFrame([{"商品名称": "测试"}])
344
+ try:
345
+ expand_master_to_options(master_no_code)
346
+ check(False, "应抛出 ValueError")
347
+ except ValueError as e:
348
+ check("主编码" in str(e), f"报错含「主编码」(实际: {e})")
349
+
350
+ master_no_name = pd.DataFrame([{"主编码": "A001"}])
351
+ try:
352
+ expand_master_to_options(master_no_name)
353
+ check(False, "应抛出 ValueError")
354
+ except ValueError as e:
355
+ check("商品名称" in str(e), f"报错含「商品名称」(实际: {e})")
356
+ print()
357
+
358
+ # ── 10. 推荐/默认不在列表中 ──
359
+ print("10. 推荐/默认不在选项列表中 → 全部「否」")
360
+ master_no_match = pd.DataFrame([{
361
+ "主编码": "A001", "商品名称": "测试",
362
+ "推荐糖度": "全糖", "默认糖度": "无糖",
363
+ "糖度": "七分糖;五分糖",
364
+ "推荐温度": "", "默认温度": "", "温度": "",
365
+ "推荐规格": "", "默认规格": "", "规格": "",
366
+ "推荐奶底": "", "默认奶底": "", "奶底": "",
367
+ "推荐茶底": "", "默认茶底": "", "茶底": "",
368
+ }])
369
+ result10 = expand_master_to_options(master_no_match)
370
+ check(all(r["推荐项"] == "否" for _, r in result10.iterrows()),
371
+ "所有行推荐项 = 否")
372
+ check(all(r["默认项"] == "否" for _, r in result10.iterrows()),
373
+ "所有行默认项 = 否")
374
+ print()
375
+
376
+ # ── 11. 推荐 == 默认(同一值) ──
377
+ print("11. 推荐 == 默认 → 该行两列都填「是」")
378
+ master_same = pd.DataFrame([{
379
+ "主编码": "A001", "商品名称": "测试",
380
+ "推荐糖度": "七分糖", "默认糖度": "七分糖",
381
+ "糖度": "七分糖;五分糖",
382
+ "推荐温度": "", "默认温度": "", "温度": "",
383
+ "推荐规格": "", "默认规格": "", "规格": "",
384
+ "推荐奶底": "", "默认奶底": "", "奶底": "",
385
+ "推荐茶底": "", "默认茶底": "", "茶底": "",
386
+ }])
387
+ result11 = expand_master_to_options(master_same)
388
+ row_7 = result11[result11["选项名称"] == "七分糖"].iloc[0]
389
+ check(row_7["推荐项"] == "是" and row_7["默认项"] == "是",
390
+ f"七分糖 → 推荐项=是, 默认项=是 (实际 {row_7['推荐项']}/{row_7['默认项']})")
391
+ row_5 = result11[result11["选项名称"] == "五分糖"].iloc[0]
392
+ check(row_5["推荐项"] == "否" and row_5["默认项"] == "否",
393
+ f"五分糖 → 推荐项=否, 默认项=否")
394
+ print()
395
+
396
+ # ── 12. 重复值去重 ──
397
+ print("12. 重复值去重")
398
+ master_dup = pd.DataFrame([{
399
+ "主编码": "A001", "商品名称": "测试",
400
+ "推荐糖度": "七分糖", "默认糖度": "五分糖",
401
+ "糖度": "七分糖;五分糖;七分糖",
402
+ "推荐温度": "", "默认温度": "", "温度": "",
403
+ "推荐规格": "", "默认规格": "", "规格": "",
404
+ "推荐奶底": "", "默认奶底": "", "奶底": "",
405
+ "推荐茶底": "", "默认茶底": "", "茶底": "",
406
+ }])
407
+ result12 = expand_master_to_options(master_dup)
408
+ check(len(result12) == 2, f"去重后 2 行(实际 {len(result12)})")
409
+ vals = set(result12["选项名称"].tolist())
410
+ check(vals == {"七分糖", "五分糖"}, f"值 = 七分糖/五分糖(实际 {vals})")
411
+ print()
412
+
413
+ # ── 13. 空白值在列表中 ──
414
+ print("13. 列表中间空白值跳过")
415
+ master_blank_mid = pd.DataFrame([{
416
+ "主编码": "A001", "商品名称": "测试",
417
+ "推荐糖度": "七分糖", "默认糖度": "五分糖",
418
+ "糖度": "七分糖; ;五分糖",
419
+ "推荐温度": "", "默认温度": "", "温度": "",
420
+ "推荐规格": "", "默认规格": "", "规格": "",
421
+ "推荐奶底": "", "默认奶底": "", "奶底": "",
422
+ "推荐茶底": "", "默认茶底": "", "茶底": "",
423
+ }])
424
+ result13 = expand_master_to_options(master_blank_mid)
425
+ check(len(result13) == 2, f"跳过空白后 2 行(实际 {len(result13)})")
426
+ print()
427
+
428
+ # ── 14. 推荐/默认值为 NaN ──
429
+ print("14. 推荐/默认值为 NaN → 全部「否」")
430
+ master_nan = pd.DataFrame([{
431
+ "主编码": "A001", "商品名称": "测试",
432
+ "推荐糖度": float("nan"), "默认糖度": None,
433
+ "糖度": "七分糖;五分糖",
434
+ "推荐温度": "", "默认温度": "", "温度": "",
435
+ "推荐规格": "", "默认规格": "", "规格": "",
436
+ "推荐奶底": "", "默认奶底": "", "奶底": "",
437
+ "推荐茶底": "", "默认茶底": "", "茶底": "",
438
+ }])
439
+ result14 = expand_master_to_options(master_nan)
440
+ check(all(r["推荐项"] == "否" for _, r in result14.iterrows()), "推荐项 全部 = 否")
441
+ check(all(r["默认项"] == "否" for _, r in result14.iterrows()), "默认项 全部 = 否")
442
+ print()
443
+
444
+ # ── 15. 所有维度列表为空(跳过整行) ──
445
+ print("15. 所有维度列表为空 → 0 行")
446
+ master_all_empty = pd.DataFrame([{
447
+ "主编码": "A001", "商品名称": "测试",
448
+ "推荐糖度": "", "默认糖度": "", "糖度": "",
449
+ "推荐温度": "", "默认温度": "", "温度": "",
450
+ "推荐规格": "", "默认规格": "", "规格": "",
451
+ "推荐奶底": "", "默认奶底": "", "奶底": "",
452
+ "推荐茶底": "", "默认茶底": "", "茶底": "",
453
+ }])
454
+ result15 = expand_master_to_options(master_all_empty)
455
+ check(len(result15) == 0, f"0 行(实际 {len(result15)})")
456
+ print()
457
+
458
+ # ── 16. "是"/"否" 常量正确(中文) ──
459
+ print("16. 「是」/「否」常量验证")
460
+ check(YES == "是", f"YES = '是'(实际 {YES!r})")
461
+ check(NO == "否", f"NO = '否'(实际 {NO!r})")
462
+ print()
463
+
464
+ # ── 17. 最少必选/最多可选始终为 1 ──
465
+ print("17. 最少必选/最多可选始终为 1")
466
+ check(MIN_REQUIRED == 1, f"最少必选 = 1(实际 {MIN_REQUIRED})")
467
+ check(MAX_OPTIONAL == 1, f"最多可选 = 1(实际 {MAX_OPTIONAL})")
468
+ for _, r in result.iterrows():
469
+ check(r["最少必选"] == 1 and r["最多可选"] == 1,
470
+ f"行「{r['选项名称']}」: 最少必选={r['最少必选']}, 最多可选={r['最多可选']}")
471
+ print()
472
+
473
+ # ── 18. 列顺序验证 ──
474
+ print("18. 模板列顺序验证")
475
+ check(list(result.columns) == TEMPLATE_COLUMNS,
476
+ f"列顺序正确(实际 {list(result.columns)})")
477
+ print()
478
+
479
+ # ── 19. ASCII 分号「;」和中文分号「;」均支持拆分 ──
480
+ print("19. ASCII 分号和中文分号均支持拆分")
481
+ vals_semicolon = _parse_dimension_list("七分糖;五分糖;三分糖")
482
+ check(vals_semicolon == ["七分糖", "五分糖", "三分糖"],
483
+ f"ASCII 分号被正确拆分(实际 {vals_semicolon})")
484
+ vals_cn = _parse_dimension_list("正常冰;少冰;去冰")
485
+ check(vals_cn == ["正常冰", "少冰", "去冰"],
486
+ f"中文分号被正确拆分(实际 {vals_cn})")
487
+ print()
488
+
489
+ # ── 汇总 ──
490
+ print(f"=== 结果: {passed} passed, {failed} failed ===")