ht32-code-mcp 0.4.0__tar.gz → 0.4.2__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.
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/PKG-INFO +1 -1
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/pyproject.toml +1 -1
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/src/ht32_code_mcp/materializer.py +168 -23
- ht32_code_mcp-0.4.2/tests/test_export_cloud_mode.py +83 -0
- ht32_code_mcp-0.4.2/tests/test_recipe_claims.py +92 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/tests/test_tools.py +49 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/uv.lock +1 -1
- ht32_code_mcp-0.4.0/tests/test_export_cloud_mode.py +0 -47
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/.gitignore +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/.gitkeep +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/.pytest_base_final_run.log +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/DEPLOY.md +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/README.md +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/e2e_scenario_a.py +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/e2e_scenario_b.py +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/smoke_http.py +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/smoke_stdio.py +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/src/ht32_code_mcp/__init__.py +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/src/ht32_code_mcp/__main__.py +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/src/ht32_code_mcp/exporter.py +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/src/ht32_code_mcp/index.py +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/src/ht32_code_mcp/knowledge.py +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/src/ht32_code_mcp/release.py +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/src/ht32_code_mcp/server.py +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/src/ht32_code_mcp/usage.py +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/tests/conftest.py +0 -0
- {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.2}/tests/test_knowledge_grounding.py +0 -0
|
@@ -95,6 +95,28 @@ def pack_release_version(pack_path: Path) -> str:
|
|
|
95
95
|
return m.group(1).decode()
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
def pack_device_algorithm(pack_path: Path, device: str) -> str | None:
|
|
99
|
+
"""DFP pdsc 中该 device 的首个 Flash 算法路径(如 `Flash/HT32F490x1_64.FLM`)。
|
|
100
|
+
|
|
101
|
+
只从随包 DFP 取,不按型号猜:M4 同一子系列不同容量用的是不同 FLM
|
|
102
|
+
(F49041=_64、F49031=_32;F49153=_128、F49163=_256;F49365=_256、F49395=_1024)。
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
with zipfile.ZipFile(pack_path) as z:
|
|
106
|
+
names = [n for n in z.namelist() if n.lower().endswith(".pdsc")]
|
|
107
|
+
if not names:
|
|
108
|
+
return None
|
|
109
|
+
data = z.read(names[0]).decode("utf-8", errors="replace")
|
|
110
|
+
except zipfile.BadZipFile:
|
|
111
|
+
return None
|
|
112
|
+
m = re.search(r'<device[^>]*Dname="' + re.escape(device) + r'"[^>]*>(.*?)</device>',
|
|
113
|
+
data, re.S)
|
|
114
|
+
if not m:
|
|
115
|
+
return None
|
|
116
|
+
alg = re.search(r'<algorithm[^>]*name="([^"]+\.FLM)"', m.group(1))
|
|
117
|
+
return alg.group(1) if alg else None
|
|
118
|
+
|
|
119
|
+
|
|
98
120
|
# ------------------------------------------------------------------ recipe 装载
|
|
99
121
|
def load_recipes(recipes_dir: Path) -> dict[str, dict]:
|
|
100
122
|
recipes: dict[str, dict] = {}
|
|
@@ -652,9 +674,11 @@ class Materializer:
|
|
|
652
674
|
# 4) Src_USER:官方字节 + 声明选项编辑 + 自有 main.c
|
|
653
675
|
src_user = stage / "project" / "Src_USER"
|
|
654
676
|
src_user.mkdir(parents=True, exist_ok=True)
|
|
655
|
-
def _resolve_target(stage: Path, target_path: str
|
|
677
|
+
def _resolve_target(stage: Path, target_path: str, *,
|
|
678
|
+
require_existing: bool = False) -> Path | None:
|
|
656
679
|
"""源集目标路径(相对工程文件目录)-> staging 内实际路径;
|
|
657
|
-
library/utilities
|
|
680
|
+
library/utilities 下的文件由完整树整体拷贝负责,默认返回 None(不重复复制);
|
|
681
|
+
require_existing=True 时返回树内路径,供「就地改写已拷贝字节」使用。"""
|
|
658
682
|
tp = target_path.replace("/", "\\")
|
|
659
683
|
if tp.startswith(".\\"):
|
|
660
684
|
return stage / "project" / "MDK_ARMv5" / tp[2:]
|
|
@@ -663,7 +687,7 @@ class Materializer:
|
|
|
663
687
|
if tp.startswith("..\\..\\"):
|
|
664
688
|
rest = tp[len("..\\..\\"):]
|
|
665
689
|
if rest.startswith(("library\\", "utilities\\", "component\\")):
|
|
666
|
-
return None
|
|
690
|
+
return stage / rest if require_existing else None
|
|
667
691
|
return stage / rest
|
|
668
692
|
raise ContractError(RECIPE_MISMATCH, f"无法解析目标路径: {target_path}")
|
|
669
693
|
|
|
@@ -675,8 +699,15 @@ class Materializer:
|
|
|
675
699
|
reason="官方示例文档,非构建资产"))
|
|
676
700
|
continue
|
|
677
701
|
target_file = _resolve_target(stage, sf["target_path"])
|
|
678
|
-
|
|
679
|
-
|
|
702
|
+
in_tree = target_file is None
|
|
703
|
+
base_name = Path(sf["origin"]).name
|
|
704
|
+
if in_tree:
|
|
705
|
+
# library/utilities 下的文件由完整官方树按字节拷贝(manifest 已由树拷贝登记);
|
|
706
|
+
# 只有带声明式选项编辑的文件才就地改写,其余保持原样、不重复登记。
|
|
707
|
+
if not any(Path(e["origin"]).name == base_name
|
|
708
|
+
for e in cfg.get("allowed_option_edits", [])):
|
|
709
|
+
continue
|
|
710
|
+
target_file = _resolve_target(stage, sf["target_path"], require_existing=True)
|
|
680
711
|
op = pkg / sf["origin"]
|
|
681
712
|
data = op.read_bytes()
|
|
682
713
|
fname = target_file.name
|
|
@@ -684,7 +715,6 @@ class Materializer:
|
|
|
684
715
|
# retarget 默认开启 = 官方默认值(_RETARGET=1),无需编辑;
|
|
685
716
|
# 非 hsi-safe 时钟 profile 用官方默认选项,不应用时钟编辑。
|
|
686
717
|
applicable = []
|
|
687
|
-
base_name = Path(sf["origin"]).name
|
|
688
718
|
for e in cfg.get("allowed_option_edits", []):
|
|
689
719
|
if Path(e["origin"]).name != base_name:
|
|
690
720
|
continue
|
|
@@ -701,13 +731,26 @@ class Materializer:
|
|
|
701
731
|
selector=e["selector"]["name"], old=e["old_value"], new=e["new_value"],
|
|
702
732
|
expected_matches=e["expected_matches"],
|
|
703
733
|
reason=f"profile {r.clock_profile}/{r.retarget_profile} 声明式官方选项差异"))
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
734
|
+
if in_tree:
|
|
735
|
+
# 就地改写树拷贝字节,并更新树拷贝已登记的那条 manifest(字节/SHA/edited)
|
|
736
|
+
target_file.write_bytes(data)
|
|
737
|
+
rel = target_file.relative_to(stage).as_posix()
|
|
738
|
+
entry = next((m for m in manifest_files if m["path"] == rel), None)
|
|
739
|
+
if entry is None:
|
|
740
|
+
manifest_files.append(dict(
|
|
741
|
+
path=rel, bytes=len(data), sha256=sha256_bytes(data),
|
|
742
|
+
classification="official",
|
|
743
|
+
origin=f"{origin['package']}/{sf['origin']}", edited=True))
|
|
744
|
+
else:
|
|
745
|
+
entry.update(bytes=len(data), sha256=sha256_bytes(data), edited=True)
|
|
746
|
+
else:
|
|
747
|
+
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
748
|
+
target_file.write_bytes(data)
|
|
749
|
+
manifest_files.append(dict(
|
|
750
|
+
path=target_file.relative_to(stage).as_posix(), bytes=len(data),
|
|
751
|
+
sha256=sha256_bytes(data), classification="official",
|
|
752
|
+
origin=f"{origin['package']}/{sf['origin']}",
|
|
753
|
+
edited=bool(applicable)))
|
|
711
754
|
# 自有 main.c
|
|
712
755
|
main_c = owned_main_source(r, cfg["device"])
|
|
713
756
|
(src_user / "main.c").write_text(main_c, encoding="utf-8", newline="")
|
|
@@ -731,12 +774,7 @@ class Materializer:
|
|
|
731
774
|
# uvoptx(受控调试配置;M4 用官方侧车,f5 用净化后的用户侧车,l5/f1 不提供)
|
|
732
775
|
uvoptx_src = self._controlled_uvoptx(r)
|
|
733
776
|
if uvoptx_src is not None:
|
|
734
|
-
data =
|
|
735
|
-
data, n = re.subn(rb"(<TargetName>)[^<]*(</TargetName>)",
|
|
736
|
-
lambda m: m.group(1) + stem.encode() + m.group(2), data, count=1)
|
|
737
|
-
if n == 1:
|
|
738
|
-
deviations.append(dict(kind="uvoptx-targetname", file=uvprojx.name,
|
|
739
|
-
reason="受控 .uvoptx 的 TargetName 与工程名同步"))
|
|
777
|
+
data = self._render_uvoptx(r, uvprojx, uvoptx_src, pack, stem, deviations)
|
|
740
778
|
target = proj_dir / f"{stem}.uvoptx"
|
|
741
779
|
target.write_bytes(data)
|
|
742
780
|
manifest_files.append(dict(
|
|
@@ -863,6 +901,23 @@ class Materializer:
|
|
|
863
901
|
if f"<{tag}>" in text:
|
|
864
902
|
text = rewrite_tag_value(text, tag, stem, what)
|
|
865
903
|
edits.append(f"{tag} -> {stem}")
|
|
904
|
+
# e2) 声明式 uvprojx 选项编辑(如 M4 统一 -O2;命中数必须精确)
|
|
905
|
+
for e in cfg.get("project_edits", []):
|
|
906
|
+
tag = e["tag"]
|
|
907
|
+
old = f"<{tag}>{e['old_value']}</{tag}>"
|
|
908
|
+
new = f"<{tag}>{e['new_value']}</{tag}>"
|
|
909
|
+
hits = text.count(old)
|
|
910
|
+
if hits != e["expected_matches"]:
|
|
911
|
+
raise ContractError(
|
|
912
|
+
RECIPE_MISMATCH,
|
|
913
|
+
f"{what}: <{tag}>{e['old_value']}</{tag}> 预期命中 "
|
|
914
|
+
f"{e['expected_matches']} 次,实际 {hits}")
|
|
915
|
+
text = text.replace(old, new)
|
|
916
|
+
edits.append(f"{tag} {e['old_value']} -> {e['new_value']}")
|
|
917
|
+
deviations.append(dict(
|
|
918
|
+
kind="project-option-edit", file=f"{stem}.uvprojx", tag=tag,
|
|
919
|
+
old=e["old_value"], new=e["new_value"],
|
|
920
|
+
reason=e.get("reason") or "声明式项目选项编辑"))
|
|
866
921
|
# f) PackID -> recipe 锁定版本(声明式)
|
|
867
922
|
pack_id = r.recipe["pack"]["id"]
|
|
868
923
|
if f"<PackID>{pack_id}</PackID>" not in text and "<PackID>" in text:
|
|
@@ -897,6 +952,92 @@ class Materializer:
|
|
|
897
952
|
f"{r.series}-controlled.uvoptx"
|
|
898
953
|
return asset if asset.is_file() else None
|
|
899
954
|
|
|
955
|
+
def _render_uvoptx(self, r: Resolved, uvprojx: Path, src: Path, pack: dict,
|
|
956
|
+
stem: str, deviations: list[dict]) -> bytes:
|
|
957
|
+
"""把受控 .uvoptx 渲染成该 config 的调试侧车。
|
|
958
|
+
|
|
959
|
+
受控资产只保留骨架与占位符;芯片相关值全部来自**已裁决的 recipe.memory +
|
|
960
|
+
随包 DFP 的 Flash 算法**,不按型号猜:
|
|
961
|
+
- device / FLM:`pack_device_algorithm()`(M4 同子系列不同容量 FLM 不同);
|
|
962
|
+
- Flash 基址/容量:`memory.flash[0]`;
|
|
963
|
+
- SW-DP ID:M4=2BA01477,M0+/M3=0BC11477;
|
|
964
|
+
- ListingPath:与 uvprojx 同步(uVision 以 uvoptx 为准);
|
|
965
|
+
- CMSIS_AGDI 注册项含 `-FO15`(Reset and Run),与 M0+ 对齐。
|
|
966
|
+
"""
|
|
967
|
+
cfg = r.config
|
|
968
|
+
what = cfg["config_id"]
|
|
969
|
+
device = cfg["device"]
|
|
970
|
+
flash = cfg["memory"]["flash"][0]
|
|
971
|
+
flash_base = int(flash["base"], 16)
|
|
972
|
+
flash_size = int(flash["size"], 16)
|
|
973
|
+
uv_text = uvprojx.read_text(encoding="utf-8", errors="replace")
|
|
974
|
+
m = re.search(r"<ListingPath>([^<]*)</ListingPath>", uv_text)
|
|
975
|
+
if not m:
|
|
976
|
+
raise ContractError(RECIPE_MISMATCH, f"{what}: uvprojx 缺 <ListingPath>")
|
|
977
|
+
listing = m.group(1)
|
|
978
|
+
m4 = r.series in ("ht32f490x1", "ht32f491x3", "ht32f493x5")
|
|
979
|
+
ram_alg = "1000"
|
|
980
|
+
if m4:
|
|
981
|
+
alg = pack_device_algorithm(pack["asset"], device)
|
|
982
|
+
if not alg:
|
|
983
|
+
raise ContractError(
|
|
984
|
+
RECIPE_MISMATCH,
|
|
985
|
+
f"{what}: 随包 DFP 未给出 {device} 的 Flash 算法(pdsc 无 algorithm)")
|
|
986
|
+
flm_path = alg.replace("/", "\\")
|
|
987
|
+
flm_base = Path(alg).stem
|
|
988
|
+
cmsis = (
|
|
989
|
+
'-X"Any" -UAny -O206 -S0 -C0 -P00 -N00("ARM CoreSight SW-DP") '
|
|
990
|
+
'-D00(2BA01477) -L00(0) -TO18 -TC10000000 -TP20 -TDS8007 -TDT0 -TDC1F '
|
|
991
|
+
'-TIEFFFFFFFF -TIP8 -FO15 -FD20000000 '
|
|
992
|
+
f"-FC{ram_alg} -FN1 -FF0{flm_base}.FLM "
|
|
993
|
+
f"-FS0{flash_base:X} -FL0{flash_size:X} "
|
|
994
|
+
f"-FP0($$Device:{device}${flm_path})")
|
|
995
|
+
ul2 = (
|
|
996
|
+
f"UL2CM3(-S0 -C0 -P0 -FD20000000 -FC{ram_alg} -FN1 -FF0{flm_base} "
|
|
997
|
+
f"-FS0{flash_base:X} -FL0{flash_size:X} "
|
|
998
|
+
f"-FP0($$Device:{device}${flm_path}))")
|
|
999
|
+
else:
|
|
1000
|
+
cmsis = (
|
|
1001
|
+
'-X"Any" -UAny -O206 -S0 -C0 -P00 -N00("ARM CoreSight SW-DP") '
|
|
1002
|
+
'-D00(0BC11477) -L00(0) -TO18 -TC1000000 -TP20 -TDS8007 -TDT0 -TDC1F '
|
|
1003
|
+
'-TIEFFFFFFFF -TIP8 -FO15 -FD20000000 '
|
|
1004
|
+
f"-FC{ram_alg} -FN2 -FF0HT32F.FLM "
|
|
1005
|
+
f"-FS0{flash_base:X} -FL0{flash_size:X} "
|
|
1006
|
+
f"-FP0($$Device:{device}$ARM\\Flash\\HT32F.FLM) "
|
|
1007
|
+
"-FF1HT32F_OPT.FLM -FS11FF00000 -FL11000 "
|
|
1008
|
+
f"-FP1($$Device:{device}$ARM\\Flash\\HT32F_OPT.FLM)")
|
|
1009
|
+
ul2 = (
|
|
1010
|
+
f"UL2CM3(-S0 -C0 -P0 -FD20000000 -FC{ram_alg} -FN2 -FF0HT32F "
|
|
1011
|
+
f"-FS0{flash_base:X} -FL0{flash_size:X} "
|
|
1012
|
+
"-FF1HT32F_OPT -FS11FF00000 -FL11000 "
|
|
1013
|
+
f"-FP0($$Device:{device}$ARM\\Flash\\HT32F.FLM) "
|
|
1014
|
+
f"-FP1($$Device:{device}$ARM\\Flash\\HT32F_OPT.FLM))")
|
|
1015
|
+
data = src.read_bytes()
|
|
1016
|
+
edits: list[str] = []
|
|
1017
|
+
for token, value in (
|
|
1018
|
+
(b"__HT32_LISTING__", listing),
|
|
1019
|
+
(b"__HT32_CMSIS_AGDI_NAME__", cmsis),
|
|
1020
|
+
(b"__HT32_UL2CM3_NAME__", ul2)):
|
|
1021
|
+
hits = data.count(token)
|
|
1022
|
+
if hits != 1:
|
|
1023
|
+
raise ContractError(
|
|
1024
|
+
RECIPE_MISMATCH,
|
|
1025
|
+
f"{what}: 受控 uvoptx 占位符 {token.decode()} 预期 1 次,实际 {hits}")
|
|
1026
|
+
data = data.replace(token, value.encode())
|
|
1027
|
+
edits.append(f"{token.decode()} -> {value[:60]}...")
|
|
1028
|
+
data, n = re.subn(rb"(<TargetName>)[^<]*(</TargetName>)",
|
|
1029
|
+
lambda mm: mm.group(1) + stem.encode() + mm.group(2),
|
|
1030
|
+
data, count=1)
|
|
1031
|
+
if n == 1:
|
|
1032
|
+
edits.append(f"TargetName -> {stem}")
|
|
1033
|
+
if b"__HT32_" in data:
|
|
1034
|
+
raise ContractError(RECIPE_MISMATCH, f"{what}: uvoptx 仍有未替换占位符")
|
|
1035
|
+
deviations.append(dict(
|
|
1036
|
+
kind="uvoptx-materialized", file=f"{stem}.uvoptx", edits=edits,
|
|
1037
|
+
reason="受控 uvoptx 按 device/FLM/memory/uvprojx ListingPath 参数化"
|
|
1038
|
+
"(含 CMSIS_AGDI -FO15 = Reset and Run)"))
|
|
1039
|
+
return data
|
|
1040
|
+
|
|
900
1041
|
# ------------------------------------------------------------ manifest
|
|
901
1042
|
def _write_manifests(self, r: Resolved, mdir: Path, stem: str, files: list[dict],
|
|
902
1043
|
deviations: list[dict], notes: list[str], total_bytes: int,
|
|
@@ -979,14 +1120,18 @@ class Materializer:
|
|
|
979
1120
|
else:
|
|
980
1121
|
lines.append("- 调试:本系列无受控 `.uvoptx`,首次打开请在 Options for Target → Debug "
|
|
981
1122
|
"选择 `CMSIS-DAP Debugger` 并保存")
|
|
1123
|
+
result_khz = (r.config.get("clock") or {}).get("result_khz")
|
|
1124
|
+
if r.clock_profile in ("hsi-safe", "hick-safe"):
|
|
1125
|
+
clock_detail = ("(内部高速时钟安全配置,不依赖外晶振;"
|
|
1126
|
+
+ (f"HSI 时钟 {result_khz / 1000:g} MHz;" if result_khz else "")
|
|
1127
|
+
+ "官方选项组合)")
|
|
1128
|
+
else:
|
|
1129
|
+
clock_detail = "(官方默认时钟配置,依赖板级外晶振)"
|
|
982
1130
|
lines += [
|
|
983
1131
|
"",
|
|
984
1132
|
"## 导出 profile",
|
|
985
1133
|
"",
|
|
986
|
-
f"- 时钟:`{r.clock_profile}`"
|
|
987
|
-
+ ("(内部高速时钟安全配置,不依赖外晶振;官方选项组合)"
|
|
988
|
-
if r.clock_profile in ("hsi-safe", "hick-safe")
|
|
989
|
-
else "(官方默认时钟配置,依赖板级外晶振)"),
|
|
1134
|
+
f"- 时钟:`{r.clock_profile}`{clock_detail}",
|
|
990
1135
|
f"- 串口:`{r.retarget_profile}`",
|
|
991
1136
|
]
|
|
992
1137
|
if r.retarget_profile == "retarget":
|
|
@@ -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"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""回归测试:retarget 串口声明(uart_pin_claims)的文案与真实头文件/宏一致。
|
|
2
|
+
|
|
3
|
+
两个已实测的缺陷(都在 build_recipes.py 的 com1_claim() 里把宏名直接拼字符串):
|
|
4
|
+
1. 版级头文件名:README 写 `per utilities/HT32_Board/ht32f50241-sk.h`,实际文件是
|
|
5
|
+
`ht32f50241_sk.h`(板名 id 用连字符、头文件名用下划线)。从 recipe 数据侧兜底:
|
|
6
|
+
每条 claim 里的头文件名都必须能在官方包 `utilities/HT32_Board/` 下找到。
|
|
7
|
+
2. 引脚宏粘连:README 写 `TX=GPIO_PAAFIO_PIN_4`,实际是「端口宏 GPIO_PA + 引脚宏
|
|
8
|
+
AFIO_PIN_4」两个独立宏,应为 `TX=GPIO_PA/AFIO_PIN_4`。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
REPO = Path(__file__).resolve().parents[3] # tests -> mcp-server -> .work -> ht32-code
|
|
17
|
+
RECIPES = REPO / ".work" / "template-recipes" / "recipes"
|
|
18
|
+
CLAIM_HEADER = re.compile(r"utilities/HT32_Board/([^)\s]+\.h)")
|
|
19
|
+
# 引脚 = 端口宏 GPIO_Px + 引脚宏 AFIO_PIN_n,中间必须有一个 '/'
|
|
20
|
+
CLAIM_PIN = re.compile(r"GPIO_P[A-H]?/AFIO_PIN_\d+")
|
|
21
|
+
CLAIM_PIN_VALUE = re.compile(r"(?:TX|RX)=([^\s,)]+)")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _recipes() -> list[dict]:
|
|
25
|
+
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(RECIPES.glob("*.json"))]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_claim_board_headers_exist_in_official_package():
|
|
29
|
+
missing: list[str] = []
|
|
30
|
+
checked = 0
|
|
31
|
+
for recipe in _recipes():
|
|
32
|
+
for cfg in recipe["configs"]:
|
|
33
|
+
for claim in (cfg.get("retarget") or {}).get("uart_pin_claims") or []:
|
|
34
|
+
m = CLAIM_HEADER.search(claim)
|
|
35
|
+
if not m:
|
|
36
|
+
continue
|
|
37
|
+
checked += 1
|
|
38
|
+
header = REPO / "raw" / cfg["origin"]["package"] / "utilities" / "HT32_Board" / m.group(1)
|
|
39
|
+
if not header.is_file():
|
|
40
|
+
missing.append(f"{cfg['config_id']}: {m.group(1)}")
|
|
41
|
+
assert checked > 0, "recipe 里没有可校验的板级头文件声明"
|
|
42
|
+
assert not missing, "claim 引用了不存在的板级头文件:\n" + "\n".join(missing)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_claim_board_headers_use_real_file_names_not_board_ids():
|
|
46
|
+
"""板名 id(连字符)不能直接当文件名;下划线才是官方文件名的分隔符。"""
|
|
47
|
+
wrong: list[str] = []
|
|
48
|
+
for recipe in _recipes():
|
|
49
|
+
for cfg in recipe["configs"]:
|
|
50
|
+
for claim in (cfg.get("retarget") or {}).get("uart_pin_claims") or []:
|
|
51
|
+
m = CLAIM_HEADER.search(claim)
|
|
52
|
+
if m and ("-sk.h" in m.group(1) or "-dvb.h" in m.group(1)):
|
|
53
|
+
wrong.append(f"{cfg['config_id']}: {m.group(1)}")
|
|
54
|
+
assert not wrong, "claim 头文件名仍用板名 id 的连字符:\n" + "\n".join(wrong)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_claim_pins_use_port_macro_slash_pin_macro():
|
|
58
|
+
"""7 份 recipe 的 TX=/RX= 引脚都必须是 GPIO_Px/AFIO_PIN_n 形态。
|
|
59
|
+
|
|
60
|
+
端口宏(GPIO_PA)与引脚宏(AFIO_PIN_4)是两个独立宏;拼成 GPIO_PAAFIO_PIN_4
|
|
61
|
+
会被用户当成宏名照抄,编译不过。
|
|
62
|
+
"""
|
|
63
|
+
bad: list[str] = []
|
|
64
|
+
checked = 0
|
|
65
|
+
for recipe in _recipes():
|
|
66
|
+
for cfg in recipe["configs"]:
|
|
67
|
+
for claim in (cfg.get("retarget") or {}).get("uart_pin_claims") or []:
|
|
68
|
+
for m in CLAIM_PIN_VALUE.finditer(claim):
|
|
69
|
+
checked += 1
|
|
70
|
+
if not CLAIM_PIN.fullmatch(m.group(1)):
|
|
71
|
+
bad.append(f"{cfg['config_id']}: {m.group(0)} <- {claim}")
|
|
72
|
+
assert checked >= 80, f"recipe 里可校验的 TX/RX 引脚过少({checked}),疑似数据缺失"
|
|
73
|
+
assert not bad, ("claim 引脚宏形态不对(应为 GPIO_Px/AFIO_PIN_n,不能写成 "
|
|
74
|
+
"GPIO_PAAFIO_PIN_n):\n" + "\n".join(bad))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_exported_readme_points_at_real_board_header(tmp_path):
|
|
78
|
+
"""导出工程 README 的串口占用应指向真实板级头文件(下划线)、引脚宏用 '/' 分隔。"""
|
|
79
|
+
from ht32_code_mcp import server as srv
|
|
80
|
+
|
|
81
|
+
out = tmp_path / "bundle_f50241"
|
|
82
|
+
text = srv.export_project_bundle("HT32F50241", str(out))
|
|
83
|
+
assert "导出失败" not in text, text
|
|
84
|
+
readme = (out / "README.md").read_text(encoding="utf-8")
|
|
85
|
+
assert "ht32f50241_sk.h" in readme, readme
|
|
86
|
+
assert "ht32f50241-sk.h" not in readme, readme
|
|
87
|
+
# README「占用」行必须给出 GPIO_PA/AFIO_PIN_4(板级头:TX=PA4 / RX=PA5)
|
|
88
|
+
claim_line = next((ln for ln in readme.splitlines() if "占用" in ln), "")
|
|
89
|
+
assert "TX=GPIO_PA/AFIO_PIN_4" in claim_line, claim_line
|
|
90
|
+
assert "RX=GPIO_PA/AFIO_PIN_5" in claim_line, claim_line
|
|
91
|
+
assert "GPIO_PAAFIO" not in readme, readme
|
|
92
|
+
|
|
@@ -478,6 +478,55 @@ def test_export_target_name_custom(tmp_path):
|
|
|
478
478
|
assert (out / "README.md").read_text(encoding="utf-8").startswith("# F52352_UART_Printf")
|
|
479
479
|
|
|
480
480
|
|
|
481
|
+
@pytest.mark.parametrize("config_id,device,flm", [
|
|
482
|
+
("ht32f49041-20tssop.ht32f49041-sk.ac5.hick-safe.retarget",
|
|
483
|
+
"HT32F49041_20TSSOP", "HT32F490x1_64"),
|
|
484
|
+
("ht32f49153-48lqfp.ht32f49163-sk.ac5.hick-safe.retarget",
|
|
485
|
+
"HT32F49153_48LQFP", "HT32F491x3_128"),
|
|
486
|
+
("ht32f49365-64lqfp.ht32f49395-sk.ac5.hick-safe.retarget",
|
|
487
|
+
"HT32F49365_64LQFP", "HT32F493x5_256"),
|
|
488
|
+
])
|
|
489
|
+
def test_export_m4_debug_options_and_optimization(tmp_path, config_id, device, flm):
|
|
490
|
+
"""M4:-O2、CMSIS_AGDI(-FO15)、FLM/device 按芯片参数化、ListingPath 同步。"""
|
|
491
|
+
out = tmp_path / config_id.split(".")[0]
|
|
492
|
+
srv.exporter.export_dir(out_dir=str(out), project_name="verify_proj", config_id=config_id)
|
|
493
|
+
uv = next((out / "project" / "MDK_ARMv5").glob("*.uvprojx"))
|
|
494
|
+
text = uv.read_text(encoding="utf-8")
|
|
495
|
+
assert "<Optim>3</Optim>" in text
|
|
496
|
+
listing = re.search(r"<ListingPath>([^<]*)</ListingPath>", text).group(1)
|
|
497
|
+
side = uv.with_suffix(".uvoptx")
|
|
498
|
+
side_text = side.read_text(encoding="utf-8")
|
|
499
|
+
assert "<Key>CMSIS_AGDI</Key>" in side_text
|
|
500
|
+
assert "<Key>UL2CM3</Key>" in side_text
|
|
501
|
+
assert "-FO15" in side_text
|
|
502
|
+
assert re.search(r"<ListingPath>" + re.escape(listing) + "</ListingPath>", side_text)
|
|
503
|
+
assert f"$$Device:{device}$" in side_text
|
|
504
|
+
assert flm in side_text
|
|
505
|
+
dev = json.loads((out / "manifest" / "deviations.json").read_text(encoding="utf-8"))
|
|
506
|
+
kinds = [d["kind"] for d in dev["deviations"]]
|
|
507
|
+
assert "project-option-edit" in kinds
|
|
508
|
+
assert "uvoptx-materialized" in kinds
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def test_export_m0_debug_sidecar_is_parameterized(tmp_path):
|
|
512
|
+
"""M0+:uvoptx 的 device/Flash 容量与 uvprojx ListingPath 同步(不再固定 F52352)。"""
|
|
513
|
+
out = tmp_path / "f50030"
|
|
514
|
+
srv.exporter.export_dir(
|
|
515
|
+
out_dir=str(out), project_name="verify_proj",
|
|
516
|
+
config_id="ht32f50030.ht32f50030-sk.ac5.hsi-safe.retarget")
|
|
517
|
+
uv = next((out / "project" / "MDK_ARMv5").glob("*.uvprojx"))
|
|
518
|
+
text = uv.read_text(encoding="utf-8")
|
|
519
|
+
listing = re.search(r"<ListingPath>([^<]*)</ListingPath>", text).group(1)
|
|
520
|
+
output_dir = re.search(r"<OutputDirectory>([^<]*)</OutputDirectory>", text).group(1)
|
|
521
|
+
# obj 与 list 是同一芯片目录下的两个并列子目录;list 不得再落回工程根 .\List\
|
|
522
|
+
assert "HT32\\50030\\Obj\\" in output_dir
|
|
523
|
+
assert "HT32\\50030\\List\\" in listing
|
|
524
|
+
side_text = uv.with_suffix(".uvoptx").read_text(encoding="utf-8")
|
|
525
|
+
assert re.search(r"<ListingPath>" + re.escape(listing) + "</ListingPath>", side_text)
|
|
526
|
+
assert "$$Device:HT32F50030$" in side_text
|
|
527
|
+
assert "$$Device:HT32F52352$" not in side_text
|
|
528
|
+
|
|
529
|
+
|
|
481
530
|
def test_export_unknown_chip(tmp_path):
|
|
482
531
|
result = srv.export_project_bundle("HT32F99999", str(tmp_path / "nope"))
|
|
483
532
|
assert "UNKNOWN_DEVICE" in result and "不在契约" in result
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|