ht32-code-mcp 0.3.2__tar.gz → 0.4.1__tar.gz

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.
Files changed (26) hide show
  1. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/PKG-INFO +1 -1
  2. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/pyproject.toml +1 -1
  3. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/exporter.py +8 -4
  4. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/materializer.py +170 -37
  5. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/server.py +10 -3
  6. ht32_code_mcp-0.4.1/tests/test_export_cloud_mode.py +83 -0
  7. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/tests/test_tools.py +84 -0
  8. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/uv.lock +1 -1
  9. ht32_code_mcp-0.3.2/tests/test_export_cloud_mode.py +0 -47
  10. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/.gitignore +0 -0
  11. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/.gitkeep +0 -0
  12. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/.pytest_base_final_run.log +0 -0
  13. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/DEPLOY.md +0 -0
  14. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/README.md +0 -0
  15. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/e2e_scenario_a.py +0 -0
  16. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/e2e_scenario_b.py +0 -0
  17. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/smoke_http.py +0 -0
  18. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/smoke_stdio.py +0 -0
  19. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/__init__.py +0 -0
  20. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/__main__.py +0 -0
  21. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/index.py +0 -0
  22. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/knowledge.py +0 -0
  23. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/release.py +0 -0
  24. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/usage.py +0 -0
  25. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/tests/conftest.py +0 -0
  26. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.1}/tests/test_knowledge_grounding.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ht32-code-mcp
3
- Version: 0.3.2
3
+ Version: 0.4.1
4
4
  Summary: HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出
5
5
  License-Expression: MIT
6
6
  Keywords: firmware,holtek,ht32,mcp,mcu
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ht32-code-mcp"
7
- version = "0.3.2"
7
+ version = "0.4.1"
8
8
  description = "HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出"
9
9
  requires-python = ">=3.11"
10
10
  license = "MIT"
@@ -69,7 +69,7 @@ class ProjectExporter:
69
69
  def export_dir(self, *, out_dir: str, project_name: str = "", config_id: str = "",
70
70
  chip: str = "", device: str = "", board: str = "",
71
71
  clock_profile: str = "", retarget_profile: str = "",
72
- include_host_tools: bool = False) -> ExportResult:
72
+ include_host_tools: bool = True) -> ExportResult:
73
73
  r = self.resolve(config_id=config_id, chip=chip, device=device, board=board,
74
74
  clock_profile=clock_profile, retarget_profile=retarget_profile)
75
75
  res = self.materializer.materialize(
@@ -81,7 +81,7 @@ class ProjectExporter:
81
81
  project_name: str = "", config_id: str = "", chip: str = "",
82
82
  device: str = "", board: str = "", clock_profile: str = "",
83
83
  retarget_profile: str = "",
84
- include_host_tools: bool = False) -> ExportResult:
84
+ include_host_tools: bool = True) -> ExportResult:
85
85
  r = self.resolve(config_id=config_id, chip=chip, device=device, board=board,
86
86
  clock_profile=clock_profile, retarget_profile=retarget_profile)
87
87
  bdir = Path(bundle_dir).expanduser().resolve()
@@ -111,7 +111,9 @@ class ProjectExporter:
111
111
  lock = res.out_dir / "manifest" / "project-lock.json"
112
112
  manifest_rel = "manifest/project-lock.json"
113
113
  requires = {
114
- "pack": r.recipe["pack"]["id"],
114
+ "pack": res.pack["id"],
115
+ "pack_min_version": res.pack["min_version"],
116
+ "pack_file": res.pack["rel"],
115
117
  "pack_note": r.recipe["pack"]["device_macro_note"],
116
118
  "keil": "MDK-ARM 5.x (AC5)",
117
119
  }
@@ -151,7 +153,9 @@ class ProjectExporter:
151
153
  + (f";冲突记录 {len(mem['conflicts'])} 条" if mem["conflicts"] else ""),
152
154
  f"- 文件数/字节: {res.files} / {res.bytes}",
153
155
  f"- 树 SHA256: {res.sha256}",
154
- f"- 前置: {r.recipe['pack']['device_macro_note']}",
156
+ f"- 前置 DFP: 随包携带 `{res.pack['rel']}`({res.pack['id']} v{res.pack['version']},"
157
+ f"SHA256 {res.pack['sha256'][:16]}…);本机已装同名 DFP 且版本 "
158
+ f">= {res.pack['min_version']} 可跳过安装",
155
159
  ]
156
160
  if r.retarget_profile == "retarget":
157
161
  claims = cfg["retarget"]["uart_pin_claims"]
@@ -35,7 +35,13 @@ PACK_REQUIRED = "PACK_REQUIRED"
35
35
  CONFIG_CLOSURE_FAILED = "CONFIG_CLOSURE_FAILED"
36
36
  RECIPE_MISMATCH = "RECIPE_MISMATCH"
37
37
 
38
- HOST_TOOL_DIRS = {"gsar", "gsars", "elink", "elink32pro", "pack"}
38
+ # host 组件目录(官方包 utilities/ 下):独立 host 工具,opt-out 时排除。
39
+ # pack/ 不在此列——DFP 安装包由 recipe 的 pack.asset 显式物化锁定版本,
40
+ # 官方包内那份未必是锁定版本(L5 包内为 1.0.77),故一律不直接拷贝。
41
+ HOST_TOOL_DIRS = {"gsar", "gsars", "elink", "elink32pro"}
42
+
43
+ # <Vendor>.<Name>.<Major.Minor.Patch>;版本号取末尾三段,故不能用 rsplit(".", 1)
44
+ PACK_ID_RE = re.compile(r"^(?P<vendor>[^.]+)\.(?P<name>.+?)\.(?P<version>\d+\.\d+\.\d+)$")
39
45
 
40
46
  # 不得外发的文件/目录(隐私与卫生,W4;由 manifest 驱动而非目录黑名单)
41
47
  FORBIDDEN_PATTERNS = [
@@ -67,6 +73,28 @@ def sha256_file(path: Path) -> str:
67
73
  return h.hexdigest()
68
74
 
69
75
 
76
+ def parse_version(text: str) -> tuple[int, ...]:
77
+ """"1.0.78" -> (1, 0, 78);DFP 版本只做数字段比较(用户口径:相同或更高即可)。"""
78
+ return tuple(int(x) for x in re.findall(r"\d+", text or "")[:3]) or (0,)
79
+
80
+
81
+ def pack_release_version(pack_path: Path) -> str:
82
+ """读 .pack(zip)内 pdsc 的首个 <release> 版本号 = 该安装包提供的 DFP 版本。"""
83
+ try:
84
+ with zipfile.ZipFile(pack_path) as z:
85
+ names = [n for n in z.namelist() if n.lower().endswith(".pdsc")]
86
+ if not names:
87
+ raise ContractError(PACK_REQUIRED,
88
+ f"{pack_path.name} 内不含 pdsc,无法确认 DFP 版本")
89
+ data = z.read(names[0])
90
+ except zipfile.BadZipFile as e: # 截断/损坏的 pack 不能被当成有效资产
91
+ raise ContractError(PACK_REQUIRED, f"{pack_path.name} 不是有效的 pack(zip): {e}")
92
+ m = re.search(rb'<release version="([0-9]+\.[0-9]+\.[0-9]+)"', data)
93
+ if not m:
94
+ raise ContractError(PACK_REQUIRED, f"{pack_path.name} 的 pdsc 未声明 release 版本")
95
+ return m.group(1).decode()
96
+
97
+
70
98
  # ------------------------------------------------------------------ recipe 装载
71
99
  def load_recipes(recipes_dir: Path) -> dict[str, dict]:
72
100
  recipes: dict[str, dict] = {}
@@ -374,6 +402,7 @@ class MaterialResult:
374
402
  manifest_dir: str
375
403
  warnings: list[str]
376
404
  deviations: list[dict]
405
+ pack: dict
377
406
 
378
407
 
379
408
  class Materializer:
@@ -426,14 +455,63 @@ class Materializer:
426
455
  notes.append(f"origin={origin['kind']} package={origin['package']}")
427
456
  return notes
428
457
 
458
+ # ------------------------------------------------------------ 随包 DFP(存在性检查)
459
+ def _resolve_pack(self, r: Resolved) -> dict:
460
+ """解析并校验 recipe 声明的 DFP 安装包资产(导出工程默认随包携带)。
461
+
462
+ 三项机器校验缺一不可,任一不成立即 PACK_REQUIRED(不静默降级、不跳过):
463
+ 1. 资产存在(仓库内 .pack 文件,随仓库检出);
464
+ 2. 资产 SHA256 == recipe 的 asset_sha256(来源锁未破);
465
+ 3. pack 内 pdsc 声明的 DFP 版本 >= 要求版本,且与锁定 id 的版本段一致
466
+ (保证工程 <PackID> 与随包安装的 DFP 版本对得上)。
467
+ """
468
+ spec = r.recipe["pack"]
469
+ if spec.get("install_policy") != "bundled":
470
+ raise ContractError(
471
+ PACK_REQUIRED,
472
+ f"{r.config_id}: pack.install_policy={spec.get('install_policy')!r} 不支持"
473
+ "(本契约只接受 bundled:DFP 必须随包携带)")
474
+ asset_rel = spec.get("asset") or ""
475
+ asset = (self.root / asset_rel).resolve() if asset_rel else None
476
+ if not asset_rel or not asset or not asset.is_file():
477
+ raise ContractError(
478
+ PACK_REQUIRED,
479
+ f"{r.config_id}: 随包 DFP 资产缺失(recipe pack.asset={asset_rel or '未声明'});"
480
+ "确认 .work/template-recipes/assets/packs/ 已随仓库检入")
481
+ digest = sha256_file(asset)
482
+ if spec.get("asset_sha256") and digest != spec["asset_sha256"]:
483
+ raise ContractError(
484
+ PACK_REQUIRED,
485
+ f"{r.config_id}: 随包 DFP 资产 SHA256 不符:{asset.name} 实测 {digest[:16]}…,"
486
+ f"recipe 锁 {spec['asset_sha256'][:16]}…(资产被替换或损坏)")
487
+ version = pack_release_version(asset)
488
+ min_version = spec.get("min_version") or ""
489
+ if parse_version(version) < parse_version(min_version):
490
+ raise ContractError(
491
+ PACK_REQUIRED,
492
+ f"{r.config_id}: 随包 DFP 版本 {version} 低于要求 {min_version}")
493
+ pid = PACK_ID_RE.match(spec["id"])
494
+ if not pid or pid.group("version") != version:
495
+ raise ContractError(
496
+ PACK_REQUIRED,
497
+ f"{r.config_id}: 随包 DFP 版本 {version} 与锁定 id {spec['id']} 不一致"
498
+ "(工程 <PackID> 将指向未随包的版本)")
499
+ return dict(id=spec["id"], version=version, min_version=min_version,
500
+ asset=asset, rel=f"utilities/pack/{asset.name}",
501
+ sha256=digest, bytes=asset.stat().st_size)
502
+
429
503
  # ------------------------------------------------------------ 文件计划
430
504
  def _copy_official_tree(self, src: Path, dst: Path, manifest_files: list[dict],
431
- pkg_name: str, *, skip_host_tools: bool = True) -> None:
505
+ pkg_name: str, *, skip_host_tools: bool = True,
506
+ skip_top: set[str] | None = None) -> None:
432
507
  for p in sorted(src.rglob("*")):
433
508
  if not p.is_file():
434
509
  continue
435
510
  rel = p.relative_to(src).as_posix()
436
- if skip_host_tools and rel.split("/")[0] in HOST_TOOL_DIRS:
511
+ top = rel.split("/")[0]
512
+ if top in (skip_top or set()):
513
+ continue
514
+ if skip_host_tools and top in HOST_TOOL_DIRS:
437
515
  continue
438
516
  target = dst / rel
439
517
  target.parent.mkdir(parents=True, exist_ok=True)
@@ -444,11 +522,13 @@ class Materializer:
444
522
  origin=f"{pkg_name}/{src.relative_to(self.raw / pkg_name).as_posix()}/{rel}"))
445
523
 
446
524
  def materialize(self, r: Resolved, out_dir: str | Path, *, project_name: str = "",
447
- include_host_tools: bool = False, verify_pack: bool = False) -> MaterialResult:
525
+ include_host_tools: bool = True) -> MaterialResult:
448
526
  out = Path(out_dir).expanduser().resolve()
449
527
  if out.exists() and any(out.iterdir()):
450
528
  raise ContractError("TARGET_EXISTS", f"目标目录非空,拒绝覆盖:{out}")
451
529
  notes = self.preflight(r)
530
+ # 随包 DFP:存在性 / 资产 SHA256 / 版本 >= min_version 三项校验,先于 staging
531
+ pack = self._resolve_pack(r)
452
532
  cfg = r.config
453
533
  origin = cfg["origin"]
454
534
  pkg = self.raw / origin["package"]
@@ -466,7 +546,7 @@ class Materializer:
466
546
  deviations: list[dict] = []
467
547
  try:
468
548
  self._build_tree(r, stage, stem, kernel, board_dir, manifest_files,
469
- deviations, include_host_tools)
549
+ deviations, include_host_tools, pack)
470
550
  # 产物卫生终检(W4):不得外发的模式
471
551
  bad = [f["path"] for f in manifest_files
472
552
  if any(re.search(p, f["path"]) for p in FORBIDDEN_PATTERNS)]
@@ -481,7 +561,7 @@ class Materializer:
481
561
  mdir = stage / "manifest"
482
562
  mdir.mkdir(parents=True, exist_ok=True)
483
563
  self._write_manifests(r, mdir, stem, manifest_files, deviations, notes,
484
- total_bytes, tree_hash, len(manifest_files))
564
+ total_bytes, tree_hash, len(manifest_files), pack)
485
565
  # 原子发布(Windows 上目录重命名可能被 AV/索引器瞬态拒绝,长退避重试)
486
566
  last_err = None
487
567
  for attempt in range(10):
@@ -505,12 +585,12 @@ class Materializer:
505
585
  return MaterialResult(out_dir=out, project_file=self._local.project_rel,
506
586
  files=len(manifest_files), bytes=total_bytes,
507
587
  sha256=tree_hash, manifest_dir="manifest",
508
- warnings=r.warnings, deviations=deviations)
588
+ warnings=r.warnings, deviations=deviations, pack=pack)
509
589
 
510
590
  # ------------------------------------------------------------ 树构建
511
591
  def _build_tree(self, r: Resolved, stage: Path, stem: str, kernel: str,
512
592
  board_dir: str, manifest_files: list[dict], deviations: list[dict],
513
- include_host_tools: bool) -> None:
593
+ include_host_tools: bool, pack: dict) -> None:
514
594
  cfg = r.config
515
595
  origin = cfg["origin"]
516
596
  pkg = self.raw / origin["package"]
@@ -520,7 +600,7 @@ class Materializer:
520
600
  lib_src = pkg / ("libraries" if kernel == "m4" else "library")
521
601
  lib_dst = stage / "library"
522
602
  self._copy_official_tree(lib_src, lib_dst, manifest_files, origin["package"])
523
- # 2) utilities:官方构建资产;host 工具默认排除(D2 已授权,opt-in 携带)
603
+ # 2) utilities:官方构建资产;host 工具(gsar/eLink32pro)默认随包
524
604
  if kernel == "m4":
525
605
  board_asset = r.recipe.get("board_asset_dir") or board_dir
526
606
  util_src = pkg / "project" / board_asset
@@ -529,14 +609,33 @@ class Materializer:
529
609
  util_src = pkg / "utilities"
530
610
  util_dst = stage / "utilities"
531
611
  if util_src.is_dir():
612
+ # 官方包内的 DFP 安装包(utilities/pack/)不直接拷:L5 包内是 1.0.77,
613
+ # 与锁定版本不符;统一由 recipe 的 pack.asset 物化锁定版本(见 2b)
532
614
  self._copy_official_tree(util_src, util_dst, manifest_files, origin["package"],
533
- skip_host_tools=not include_host_tools)
615
+ skip_host_tools=not include_host_tools,
616
+ skip_top={"pack"})
534
617
  if not include_host_tools and kernel != "m4":
535
618
  deviations.append(dict(
536
619
  kind="host-tools-excluded",
537
- detail="utilities/{gsar,elink32pro,pack} 为独立 host 组件,默认 profile 不分发"
538
- "(D2 已授权可分发;opt-in include_host_tools 携带)",
620
+ detail="utilities/{gsar,elink32pro} 为独立 host 组件,本 profile 不分发"
621
+ "(D2 已授权可分发;include_host_tools=true 时携带)",
539
622
  reason="report section 4.7 project boundary; licensing approved (D2)"))
623
+ # 2b) 随包 DFP 安装包(已通过存在性/SHA256/版本三项校验)
624
+ pack_dst = stage / pack["rel"]
625
+ pack_dst.parent.mkdir(parents=True, exist_ok=True)
626
+ shutil.copyfile(pack["asset"], pack_dst)
627
+ manifest_files.append(dict(
628
+ path=pack["rel"], bytes=pack["bytes"], sha256=pack["sha256"],
629
+ classification="official",
630
+ origin=f"vendor-pack:{pack['id']}(Holtek 官方 DFP 安装包,"
631
+ "https://www.holtek.com.tw/pack)"))
632
+ deviations.append(dict(
633
+ kind="dfp-pack-bundled",
634
+ file=pack["rel"],
635
+ detail=f"{pack['id']} v{pack['version']},{pack['bytes']} B,"
636
+ f"SHA256 {pack['sha256'][:16]}…(源 holtek.com.tw/pack,资产冻结在仓库)",
637
+ reason="用户决策 2026-10-18:DFP 默认随范例工程携带,免去用户自行寻找安装包;"
638
+ f"本机已装同名 DFP 且版本 >= {pack['min_version']} 时可跳过安装"))
540
639
  # 3) licenses:官方许可文件
541
640
  lic_dst = stage / "licenses"
542
641
  lic_dst.mkdir(parents=True, exist_ok=True)
@@ -553,9 +652,11 @@ class Materializer:
553
652
  # 4) Src_USER:官方字节 + 声明选项编辑 + 自有 main.c
554
653
  src_user = stage / "project" / "Src_USER"
555
654
  src_user.mkdir(parents=True, exist_ok=True)
556
- def _resolve_target(stage: Path, target_path: str) -> Path | None:
655
+ def _resolve_target(stage: Path, target_path: str, *,
656
+ require_existing: bool = False) -> Path | None:
557
657
  """源集目标路径(相对工程文件目录)-> staging 内实际路径;
558
- library/utilities 下的文件由完整树整体拷贝负责,不重复复制(返回 None)。"""
658
+ library/utilities 下的文件由完整树整体拷贝负责,默认返回 None(不重复复制);
659
+ require_existing=True 时返回树内路径,供「就地改写已拷贝字节」使用。"""
559
660
  tp = target_path.replace("/", "\\")
560
661
  if tp.startswith(".\\"):
561
662
  return stage / "project" / "MDK_ARMv5" / tp[2:]
@@ -564,7 +665,7 @@ class Materializer:
564
665
  if tp.startswith("..\\..\\"):
565
666
  rest = tp[len("..\\..\\"):]
566
667
  if rest.startswith(("library\\", "utilities\\", "component\\")):
567
- return None # 完整官方树已拷贝
668
+ return stage / rest if require_existing else None
568
669
  return stage / rest
569
670
  raise ContractError(RECIPE_MISMATCH, f"无法解析目标路径: {target_path}")
570
671
 
@@ -576,8 +677,15 @@ class Materializer:
576
677
  reason="官方示例文档,非构建资产"))
577
678
  continue
578
679
  target_file = _resolve_target(stage, sf["target_path"])
579
- if target_file is None:
580
- continue # library/utilities 完整树已覆盖(manifest 由树拷贝登记)
680
+ in_tree = target_file is None
681
+ base_name = Path(sf["origin"]).name
682
+ if in_tree:
683
+ # library/utilities 下的文件由完整官方树按字节拷贝(manifest 已由树拷贝登记);
684
+ # 只有带声明式选项编辑的文件才就地改写,其余保持原样、不重复登记。
685
+ if not any(Path(e["origin"]).name == base_name
686
+ for e in cfg.get("allowed_option_edits", [])):
687
+ continue
688
+ target_file = _resolve_target(stage, sf["target_path"], require_existing=True)
581
689
  op = pkg / sf["origin"]
582
690
  data = op.read_bytes()
583
691
  fname = target_file.name
@@ -585,7 +693,6 @@ class Materializer:
585
693
  # retarget 默认开启 = 官方默认值(_RETARGET=1),无需编辑;
586
694
  # 非 hsi-safe 时钟 profile 用官方默认选项,不应用时钟编辑。
587
695
  applicable = []
588
- base_name = Path(sf["origin"]).name
589
696
  for e in cfg.get("allowed_option_edits", []):
590
697
  if Path(e["origin"]).name != base_name:
591
698
  continue
@@ -602,13 +709,26 @@ class Materializer:
602
709
  selector=e["selector"]["name"], old=e["old_value"], new=e["new_value"],
603
710
  expected_matches=e["expected_matches"],
604
711
  reason=f"profile {r.clock_profile}/{r.retarget_profile} 声明式官方选项差异"))
605
- target_file.parent.mkdir(parents=True, exist_ok=True)
606
- target_file.write_bytes(data)
607
- manifest_files.append(dict(
608
- path=target_file.relative_to(stage).as_posix(), bytes=len(data),
609
- sha256=sha256_bytes(data), classification="official",
610
- origin=f"{origin['package']}/{sf['origin']}",
611
- edited=bool(applicable)))
712
+ if in_tree:
713
+ # 就地改写树拷贝字节,并更新树拷贝已登记的那条 manifest(字节/SHA/edited)
714
+ target_file.write_bytes(data)
715
+ rel = target_file.relative_to(stage).as_posix()
716
+ entry = next((m for m in manifest_files if m["path"] == rel), None)
717
+ if entry is None:
718
+ manifest_files.append(dict(
719
+ path=rel, bytes=len(data), sha256=sha256_bytes(data),
720
+ classification="official",
721
+ origin=f"{origin['package']}/{sf['origin']}", edited=True))
722
+ else:
723
+ entry.update(bytes=len(data), sha256=sha256_bytes(data), edited=True)
724
+ else:
725
+ target_file.parent.mkdir(parents=True, exist_ok=True)
726
+ target_file.write_bytes(data)
727
+ manifest_files.append(dict(
728
+ path=target_file.relative_to(stage).as_posix(), bytes=len(data),
729
+ sha256=sha256_bytes(data), classification="official",
730
+ origin=f"{origin['package']}/{sf['origin']}",
731
+ edited=bool(applicable)))
612
732
  # 自有 main.c
613
733
  main_c = owned_main_source(r, cfg["device"])
614
734
  (src_user / "main.c").write_text(main_c, encoding="utf-8", newline="")
@@ -646,7 +766,7 @@ class Materializer:
646
766
  origin=str(uvoptx_src.relative_to(self.root).as_posix())))
647
767
 
648
768
  # 6) README(用户工程说明,与真实策略一致)
649
- readme = self._readme(r, stem)
769
+ readme = self._readme(r, stem, pack)
650
770
  (stage / "README.md").write_text(readme, encoding="utf-8")
651
771
  manifest_files.append(dict(path="README.md", bytes=len(readme.encode()),
652
772
  sha256=sha256_bytes(readme.encode()),
@@ -801,7 +921,7 @@ class Materializer:
801
921
  # ------------------------------------------------------------ manifest
802
922
  def _write_manifests(self, r: Resolved, mdir: Path, stem: str, files: list[dict],
803
923
  deviations: list[dict], notes: list[str], total_bytes: int,
804
- tree_hash: str, file_count: int) -> None:
924
+ tree_hash: str, file_count: int, pack: dict) -> None:
805
925
  cfg = r.config
806
926
  lock = {
807
927
  "contract_version": CONTRACT_VERSION,
@@ -814,8 +934,14 @@ class Materializer:
814
934
  "profiles": {"clock": r.clock_profile, "retarget": r.retarget_profile},
815
935
  "origin": cfg["origin"],
816
936
  "memory": cfg["memory"],
817
- "pack": r.recipe["pack"],
818
- "requires": {"pack": r.recipe["pack"]["id"],
937
+ "pack": {"id": pack["id"], "version": pack["version"],
938
+ "min_version": pack["min_version"], "file": pack["rel"],
939
+ "sha256": pack["sha256"], "bytes": pack["bytes"],
940
+ "source": "Holtek 官方 pack(https://www.holtek.com.tw/pack)",
941
+ "install_hint": "首次使用在本工程内双击安装;"
942
+ f"本机已装同名 DFP 且版本 >= {pack['min_version']} 时可跳过"},
943
+ "requires": {"pack": pack["id"],
944
+ "pack_min_version": pack["min_version"],
819
945
  "pack_note": r.recipe["pack"]["device_macro_note"],
820
946
  "keil": "MDK-ARM 5.x (AC5), UV4 command line verified"},
821
947
  "licensing": cfg["licensing"],
@@ -839,7 +965,7 @@ class Materializer:
839
965
  "compile_command": f'UV4 -b "{self._local.project_rel}" -j0 -o build.log',
840
966
  }, ensure_ascii=False, indent=1), encoding="utf-8")
841
967
 
842
- def _readme(self, r: Resolved, stem: str) -> str:
968
+ def _readme(self, r: Resolved, stem: str, pack: dict) -> str:
843
969
  cfg = r.config
844
970
  claims = cfg["retarget"]["uart_pin_claims"]
845
971
  kernel = "m4" if r.series in ("ht32f493x5", "ht32f490x1", "ht32f491x3") else "m0m3"
@@ -855,8 +981,10 @@ class Materializer:
855
981
  f"- Keil MDK-ARM 5.x,编译器 **AC5(armcc)**(本地以 ARMCC V5.06 update 4/7 实测)"
856
982
  f"——**本工程暂不支持 AC6(armclang)**,请在 Options for Target → Target 保持"
857
983
  f"编译器版本为 AC5;切到 AC6 可能在编译/链接阶段报错",
858
- f"- {r.recipe['pack']['device_macro_note']}(manifest 锁定 "
859
- f"`{r.recipe['pack']['id']}`)",
984
+ f"- DFP(器件支持包)**已随本工程提供**:`{pack['rel']}`"
985
+ f"({pack['id']},版本 {pack['version']},SHA256 `{pack['sha256'][:16]}…`)"
986
+ f"——首次使用在本工程内双击该文件安装(或 Keil Pack Installer → File → Import);"
987
+ f"**本机已装同名 DFP 且版本 ≥ {pack['min_version']} 时可跳过安装**",
860
988
  "",
861
989
  "## 打开与编译",
862
990
  "",
@@ -872,14 +1000,18 @@ class Materializer:
872
1000
  else:
873
1001
  lines.append("- 调试:本系列无受控 `.uvoptx`,首次打开请在 Options for Target → Debug "
874
1002
  "选择 `CMSIS-DAP Debugger` 并保存")
1003
+ result_khz = (r.config.get("clock") or {}).get("result_khz")
1004
+ if r.clock_profile in ("hsi-safe", "hick-safe"):
1005
+ clock_detail = ("(内部高速时钟安全配置,不依赖外晶振;"
1006
+ + (f"HSI 时钟 {result_khz / 1000:g} MHz;" if result_khz else "")
1007
+ + "官方选项组合)")
1008
+ else:
1009
+ clock_detail = "(官方默认时钟配置,依赖板级外晶振)"
875
1010
  lines += [
876
1011
  "",
877
1012
  "## 导出 profile",
878
1013
  "",
879
- f"- 时钟:`{r.clock_profile}`"
880
- + ("(内部高速时钟安全配置,不依赖外晶振;官方选项组合)"
881
- if r.clock_profile in ("hsi-safe", "hick-safe")
882
- else "(官方默认时钟配置,依赖板级外晶振)"),
1014
+ f"- 时钟:`{r.clock_profile}`{clock_detail}",
883
1015
  f"- 串口:`{r.retarget_profile}`",
884
1016
  ]
885
1017
  if r.retarget_profile == "retarget":
@@ -894,7 +1026,8 @@ class Materializer:
894
1026
  "| 目录 | 内容 |",
895
1027
  "|---|---|",
896
1028
  "| `library/` | 完整官方库(字节冻结,manifest/files.json 可逐文件核对) |",
897
- "| `utilities/` | 官方板级/公共构建资产(默认不含 gsar/eLink32pro/pack 等 host 工具) |",
1029
+ "| `utilities/` | 官方板级/公共构建资产 + host 工具(`gsar/`、`elink32pro/`)"
1030
+ "+ DFP 安装包(`pack/`,见上「前置条件」) |",
898
1031
  "| `project/MDK_ARMv5/` | 工程文件(由官方逐 IC 工程声明式物化) |",
899
1032
  "| `project/Src_USER/` | 用户源码:自有 `main.c` + 官方配置/中断文件 |",
900
1033
  "| `licenses/` | 官方许可文件 |",
@@ -441,6 +441,9 @@ def get_project_manifest() -> str:
441
441
  "工程文件按工程名重命名为 <project_name>.uvprojx,模板自用文件"
442
442
  "(devices.json / 模板维护文档)不外发,根目录改为生成属于用户工程的 README.md;"
443
443
  "project_name 缺省为型号——**应先向用户确认工程名**,用户同意后才用型号兜底。")
444
+ lines.append("DFP 随包:导出工程 `utilities/pack/` 内即为 recipe 锁定的官方 DFP 安装包"
445
+ "(版本与资产 SHA256 在导出前机器校验,不符即报 PACK_REQUIRED),"
446
+ "用户首次使用在本工程内安装即可;已装同名 DFP 且版本不低于要求版本时可跳过。")
444
447
  lines.append("交付方式:" + (
445
448
  f"云端 zip 下载(随机 token + {BUNDLE_TTL}s TTL,返回下载 URL,out_dir 忽略)"
446
449
  if CLOUD_BUNDLE else "本地目录导出(out_dir 必填)"))
@@ -457,7 +460,7 @@ def export_project_bundle(
457
460
  board: str = "",
458
461
  clock_profile: str = "",
459
462
  retarget_profile: str = "",
460
- include_host_tools: bool = False,
463
+ include_host_tools: bool = True,
461
464
  ) -> str:
462
465
  """导出自包含可编译 Keil 工程(v2 契约:recipe 物化 + manifest + 原子发布)。
463
466
 
@@ -467,6 +470,10 @@ def export_project_bundle(
467
470
  响应含 contract_version/config_id/recipe_revision/device/board/compiler/
468
471
  profiles/工程文件/manifest/树 SHA256/前置依赖/warnings/validation。
469
472
 
473
+ DFP(器件支持包)随包携带:工程 `utilities/pack/` 内即为 recipe 锁定的官方 DFP
474
+ 安装包(资产 SHA256 与版本在物化前机器校验,不符即 PACK_REQUIRED 报错),
475
+ 用户首次使用在本工程内安装即可;本机已装同名 DFP 且版本不低于要求版本时可跳过。
476
+
470
477
  两种交付形态(由服务端环境决定,无需调用方判断):
471
478
  - 云端模式:返回带 TTL 的 zip 下载 URL(含 SHA256 与大小),out_dir 被忽略。
472
479
  - 本地模式:物化到 out_dir(须为空目录或不存在;不会覆盖已有内容)。
@@ -485,8 +492,8 @@ def export_project_bundle(
485
492
  out_dir: 输出目录(本地模式必填;云端模式忽略)。
486
493
  project_name: 工程名(可选)——工程文件名主干、Target 名与输出名;
487
494
  缺省取型号;**调用方应先向用户确认工程名**。
488
- include_host_tools: 是否随包携带 host 工具(gsar/eLink32pro/pack,
489
- 默认不携带;D2 已授权可分发)。
495
+ include_host_tools: 是否随包携带 host 工具(gsar/eLink32pro;默认 True,
496
+ D2 已授权可分发)。DFP 安装包不受此开关影响——它始终随包携带。
490
497
  """
491
498
  try:
492
499
  if CLOUD_BUNDLE:
@@ -0,0 +1,83 @@
1
+ """回归测试:导出侧实测暴露的三个缺陷。
2
+
3
+ 1) 云端 zip 模式下响应格式化用了未定义的 `_fmt_time` → 线上 `export_project_bundle`
4
+ 100% 失败(本地模式与直接调 `exporter.export_zip` 都覆盖不到这行)。
5
+ 2) 物化器的单次调用状态(`_stage` / `_project_rel`)曾挂在进程级单例实例上,
6
+ 并发导出互相覆盖 → "…is not in the subpath of…" 串台错误。
7
+ 3) 默认时钟 profile `hsi-safe` 曾整体失效:官方 system 文件在 `library/` 下,由完整官方树
8
+ 按字节拷贝,而选项编辑只作用于 `_resolve_target` 能定位到 staging 的文件 → 声明式编辑
9
+ 从未落到 system 文件,导出工程仍依赖外部晶振(HSE)。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import concurrent.futures
14
+ import json
15
+ import re
16
+
17
+ from ht32_code_mcp import server as srv
18
+
19
+ CFG_F52352 = "ht32f52352.ht32f52352-sk.ac5.hsi-safe.retarget"
20
+ CFG_F50241 = "ht32f50241.ht32f50241-sk.ac5.hsi-safe.retarget"
21
+ CFG_L52241 = "ht32l52241.ht32l52241-sk.ac5.hsi-safe.retarget"
22
+
23
+
24
+ def test_cloud_zip_response_formats_expiry(tmp_path, monkeypatch):
25
+ """云端模式必须能给出下载 URL 与有效期(不得因未定义的 helper 直接抛 NameError)。"""
26
+ monkeypatch.setattr(srv, "CLOUD_BUNDLE", True)
27
+ monkeypatch.setattr(srv, "BUNDLE_DIR", str(tmp_path / "bundles"))
28
+ monkeypatch.setattr(srv, "BUNDLE_BASE_URL", "http://example.test/ht32-bundles")
29
+ text = srv.export_project_bundle(config_id=CFG_F52352, project_name="CloudMode")
30
+ assert "导出失败" not in text, text
31
+ assert "下载 URL" in text and "http://example.test/ht32-bundles/" in text
32
+ assert "有效期" in text
33
+ assert list(tmp_path.joinpath("bundles").glob("*.zip")), "未生成 zip 产物"
34
+
35
+
36
+ def test_concurrent_exports_do_not_cross_talk(tmp_path):
37
+ """并发导出必须各自落到自己的目录(不得因共享实例状态串台)。"""
38
+ jobs = [(CFG_F50241, "ConcA"), (CFG_L52241, "ConcB")]
39
+
40
+ def run(item):
41
+ idx, (cid, name) = item
42
+ return srv.export_project_bundle(
43
+ config_id=cid, project_name=name, out_dir=str(tmp_path / f"out{idx}"))
44
+
45
+ with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
46
+ texts = list(pool.map(run, enumerate(jobs)))
47
+
48
+ for text, (_, name) in zip(texts, jobs):
49
+ assert "导出失败" not in text, text
50
+ for idx, (_, name) in enumerate(jobs):
51
+ proj = tmp_path / f"out{idx}" / "project" / "MDK_ARMv5" / f"{name}.uvprojx"
52
+ assert proj.is_file(), f"并发导出产物缺失:{proj}"
53
+
54
+
55
+ def test_hsi_safe_profile_edits_system_file_in_library_tree(tmp_path):
56
+ """hsi-safe 默认 profile 必须真正改写官方 system 文件(不得因 tree 拷贝路径整条跳过)。
57
+
58
+ F50241 的官方 system 文件属 hse-direct 家族(HCLK_SRC=2/HSE),编辑器应把它改成
59
+ HSE_ENABLE=0 + HCLK_SRC=3(HSI,20 MHz 满频),并在 deviations 留 option-edit 记录。
60
+ """
61
+ out = tmp_path / "out"
62
+ text = srv.export_project_bundle(config_id=CFG_F50241, project_name="HsiSafe",
63
+ out_dir=str(out))
64
+ assert "导出失败" not in text, text
65
+
66
+ # 完整官方树里有 ~20 个 system_*.c;只有本配置选中的那个应被改写
67
+ dev = json.loads((out / "manifest" / "deviations.json").read_text(encoding="utf-8"))
68
+ edits = [d for d in dev["deviations"] if d["kind"] == "option-edit"]
69
+ assert {d["selector"] for d in edits} >= {"HSE_ENABLE", "HCLK_SRC"}, f"缺 option-edit 记录:{edits}"
70
+ assert len({d["file"] for d in edits}) == 1, f"option-edit 跨了多个文件:{edits}"
71
+ sys_name = edits[0]["file"]
72
+
73
+ target = next((p for p in out.glob("library/Device/**/system_*.c") if p.name == sys_name), None)
74
+ assert target is not None, f"导出物里找不到 {sys_name}"
75
+ code = target.read_text(encoding="utf-8", errors="replace")
76
+ assert re.search(r"#define\s+HSE_ENABLE\s+\(0\)", code), "HSE_ENABLE 未被改写为 0"
77
+ assert re.search(r"#define\s+HCLK_SRC\s+\(3\)", code), "HCLK_SRC 未被改写为 3(HSI)"
78
+
79
+ files = json.loads((out / "manifest" / "files.json").read_text(encoding="utf-8"))
80
+ paths = [f["path"] for f in files["files"]]
81
+ assert len(paths) == len(set(paths)), "同一路径被重复登记(树拷贝 + 源集各一次)"
82
+ entry = next(f for f in files["files"] if f["path"].endswith("/" + sys_name))
83
+ assert entry.get("edited") is True, "system 文件的 manifest 条目未标记 edited"
@@ -4,6 +4,7 @@
4
4
  """
5
5
  from __future__ import annotations
6
6
 
7
+ import hashlib
7
8
  import json
8
9
  import os
9
10
  import re
@@ -15,6 +16,7 @@ from pathlib import Path
15
16
  import pytest
16
17
 
17
18
  from ht32_code_mcp import server as srv
19
+ from ht32_code_mcp.materializer import pack_release_version
18
20
  from ht32_code_mcp.usage import UsageMiddleware
19
21
 
20
22
  FWLIB = "raw/HT32_STD_5xxxx_FWLib_V1.21.1"
@@ -287,12 +289,94 @@ def _bundle_checks_v2(out_dir: Path, device: str, config_id: str = ""):
287
289
  official = [f for f in files["files"] if f["classification"] == "official"]
288
290
  assert official, "没有任何 official 分类文件"
289
291
  assert all(f.get("origin") for f in official), "official 文件缺 origin"
292
+ # DFP 随包(用户决策 2026-10-18):安装包在工程内,版本/SHA256/包内 pdsc 三者与 lock 一致
293
+ pack = lock["pack"]
294
+ assert lock["requires"]["pack"] == pack["id"]
295
+ assert lock["requires"]["pack_min_version"] == pack["min_version"]
296
+ assert pack["version"] == pack["min_version"], "随包 DFP 版本必须等于要求版本(锁定版本)"
297
+ assert pack["file"].startswith("utilities/pack/")
298
+ pack_file = out_dir / pack["file"]
299
+ assert pack_file.is_file(), f"随包 DFP 缺失:{pack['file']}"
300
+ assert pack_file.stat().st_size == pack["bytes"]
301
+ assert hashlib.sha256(pack_file.read_bytes()).hexdigest() == pack["sha256"]
302
+ assert pack_release_version(pack_file) == pack["version"], "包内 pdsc 版本与 lock 不符"
303
+ devs = json.loads((mdir / "deviations.json").read_text(encoding="utf-8"))["deviations"]
304
+ assert any(d["kind"] == "dfp-pack-bundled" for d in devs), "deviations 未登记随包 DFP"
305
+ assert pack["file"] in readme, "导出 README 未指明随包 DFP 安装包"
290
306
  # 卫生:不得含 GUI 状态/构建产物/个人痕迹
291
307
  for f in files["files"]:
292
308
  assert not re.search(r"(^|/)\.uvguix\.|(^|/)Obj/|(^|/)List/", f["path"])
293
309
  assert not re.search(r"\.(axf|hex|bin|map|dep|crf|o|bak|iex|lnp)$", f["path"], re.I)
294
310
 
295
311
 
312
+ def _pack_spec(chip: str = "HT32F52352") -> dict:
313
+ """取(就地可改的)recipe pack 块:Resolved.recipe 直接引用装载后的 recipe 字典。"""
314
+ return srv.exporter.materializer.resolve(chip=chip).recipe["pack"]
315
+
316
+
317
+ def test_export_l5_ships_locked_dfp_not_package_copy(tmp_path):
318
+ """L5 官方包内自带的 .pack 是 1.0.77(低于锁定 1.0.78);随包必须是锁定的 1.0.78。"""
319
+ out = tmp_path / "l5_pack"
320
+ srv.export_project_bundle("HT32L52241", str(out))
321
+ lock = json.loads((out / "manifest" / "project-lock.json").read_text(encoding="utf-8"))
322
+ assert lock["pack"]["version"] == "1.0.78"
323
+ assert lock["pack"]["file"] == "utilities/pack/Holtek.HT32_DFP.1.0.78.pack"
324
+ in_pkg = (REPO / "raw" / "HT32_STD_L5xxxx_FWLib_V1.6.1" / "utilities" / "pack"
325
+ / "Holtek.HT32_DFP.latest.pack")
326
+ assert pack_release_version(in_pkg) == "1.0.77" # 登记事实:官方包内那份低一版
327
+ shipped = out / lock["pack"]["file"]
328
+ assert shipped.read_bytes() != in_pkg.read_bytes(), "不得直接沿用官方包内的 1.0.77"
329
+
330
+
331
+ def test_export_pack_required_when_asset_missing(tmp_path, monkeypatch):
332
+ """随包 DFP 资产缺失必须失败(PACK_REQUIRED),且不留半成品目录。"""
333
+ monkeypatch.setitem(_pack_spec(), "asset",
334
+ ".work/template-recipes/assets/packs/missing.pack")
335
+ out = tmp_path / "missing_pack"
336
+ text = srv.export_project_bundle("HT32F52352", str(out))
337
+ assert "[PACK_REQUIRED]" in text and "资产缺失" in text, text
338
+ assert not out.exists(), "失败不得留下半成品目录"
339
+
340
+
341
+ def test_export_pack_required_when_asset_hash_mismatch(tmp_path, monkeypatch):
342
+ """资产 SHA256 与 recipe 锁不一致必须失败(资产被替换/损坏)。"""
343
+ monkeypatch.setitem(_pack_spec(), "asset_sha256", "0" * 64)
344
+ out = tmp_path / "bad_hash"
345
+ text = srv.export_project_bundle("HT32F52352", str(out))
346
+ assert "[PACK_REQUIRED]" in text and "SHA256 不符" in text, text
347
+ assert not out.exists()
348
+
349
+
350
+ def test_export_pack_required_when_version_below_or_id_mismatch(tmp_path, monkeypatch):
351
+ """版本门槛:随包版本低于 min_version、或与锁定 id 版本不一致,都必须失败。"""
352
+ pack = _pack_spec()
353
+ with monkeypatch.context() as m:
354
+ m.setitem(pack, "min_version", "9.9.9")
355
+ text = srv.export_project_bundle("HT32F52352", str(tmp_path / "low_ver"))
356
+ assert "[PACK_REQUIRED]" in text and "低于要求" in text, text
357
+ with monkeypatch.context() as m:
358
+ m.setitem(pack, "id", "Holtek.HT32_DFP.9.9.9")
359
+ text = srv.export_project_bundle("HT32F52352", str(tmp_path / "id_mismatch"))
360
+ assert "[PACK_REQUIRED]" in text and "与锁定 id" in text, text
361
+ assert not (tmp_path / "low_ver").exists()
362
+ assert not (tmp_path / "id_mismatch").exists()
363
+
364
+
365
+ def test_export_host_tools_default_included(tmp_path):
366
+ """host 工具(gsar/eLink32pro)默认随包;置 false 时排除并登记 deviations。"""
367
+ out = tmp_path / "host_default"
368
+ srv.export_project_bundle("HT32F52352", str(out))
369
+ assert (out / "utilities" / "gsar").is_dir()
370
+ assert (out / "utilities" / "elink32pro" / "eLink32pro.exe").is_file()
371
+ out2 = tmp_path / "host_excluded"
372
+ srv.export_project_bundle("HT32F52352", str(out2), include_host_tools=False)
373
+ assert not (out2 / "utilities" / "gsar").exists()
374
+ assert (out2 / "utilities" / "pack").is_dir(), "host 开关不得影响随包 DFP"
375
+ devs = json.loads((out2 / "manifest" / "deviations.json")
376
+ .read_text(encoding="utf-8"))["deviations"]
377
+ assert any(d["kind"] == "host-tools-excluded" for d in devs)
378
+
379
+
296
380
  def test_export_positive_default_chip(tmp_path):
297
381
  out = tmp_path / "bundle_default"
298
382
  result = srv.export_project_bundle("HT32F52352", str(out))
@@ -440,7 +440,7 @@ wheels = [
440
440
 
441
441
  [[package]]
442
442
  name = "ht32-code-mcp"
443
- version = "0.3.2"
443
+ version = "0.4.1"
444
444
  source = { editable = "." }
445
445
  dependencies = [
446
446
  { name = "fastmcp" },
@@ -1,47 +0,0 @@
1
- """回归测试:线上实测暴露的两个导出缺陷。
2
-
3
- 1) 云端 zip 模式下响应格式化用了未定义的 `_fmt_time` → 线上 `export_project_bundle`
4
- 100% 失败(本地模式与直接调 `exporter.export_zip` 都覆盖不到这行)。
5
- 2) 物化器的单次调用状态(`_stage` / `_project_rel`)曾挂在进程级单例实例上,
6
- 并发导出互相覆盖 → "…is not in the subpath of…" 串台错误。
7
- """
8
- from __future__ import annotations
9
-
10
- import concurrent.futures
11
-
12
- from ht32_code_mcp import server as srv
13
-
14
- CFG_F52352 = "ht32f52352.ht32f52352-sk.ac5.hsi-safe.retarget"
15
- CFG_F50241 = "ht32f50241.ht32f50241-sk.ac5.hsi-safe.retarget"
16
- CFG_L52241 = "ht32l52241.ht32l52241-sk.ac5.hsi-safe.retarget"
17
-
18
-
19
- def test_cloud_zip_response_formats_expiry(tmp_path, monkeypatch):
20
- """云端模式必须能给出下载 URL 与有效期(不得因未定义的 helper 直接抛 NameError)。"""
21
- monkeypatch.setattr(srv, "CLOUD_BUNDLE", True)
22
- monkeypatch.setattr(srv, "BUNDLE_DIR", str(tmp_path / "bundles"))
23
- monkeypatch.setattr(srv, "BUNDLE_BASE_URL", "http://example.test/ht32-bundles")
24
- text = srv.export_project_bundle(config_id=CFG_F52352, project_name="CloudMode")
25
- assert "导出失败" not in text, text
26
- assert "下载 URL" in text and "http://example.test/ht32-bundles/" in text
27
- assert "有效期" in text
28
- assert list(tmp_path.joinpath("bundles").glob("*.zip")), "未生成 zip 产物"
29
-
30
-
31
- def test_concurrent_exports_do_not_cross_talk(tmp_path):
32
- """并发导出必须各自落到自己的目录(不得因共享实例状态串台)。"""
33
- jobs = [(CFG_F50241, "ConcA"), (CFG_L52241, "ConcB")]
34
-
35
- def run(item):
36
- idx, (cid, name) = item
37
- return srv.export_project_bundle(
38
- config_id=cid, project_name=name, out_dir=str(tmp_path / f"out{idx}"))
39
-
40
- with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
41
- texts = list(pool.map(run, enumerate(jobs)))
42
-
43
- for text, (_, name) in zip(texts, jobs):
44
- assert "导出失败" not in text, text
45
- for idx, (_, name) in enumerate(jobs):
46
- proj = tmp_path / f"out{idx}" / "project" / "MDK_ARMv5" / f"{name}.uvprojx"
47
- assert proj.is_file(), f"并发导出产物缺失:{proj}"
File without changes
File without changes
File without changes
File without changes