@buaa_smat/hometrans 0.1.0 → 0.1.2

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 (136) hide show
  1. package/README.md +141 -124
  2. package/agents/build-fixer.md +1 -0
  3. package/agents/code-review-fix.md +1 -0
  4. package/agents/code-reviewer.md +1 -0
  5. package/agents/logic-coding.md +1 -0
  6. package/agents/logic-context-builder.md +1 -0
  7. package/agents/review-fixer.md +1 -0
  8. package/agents/self-test-fixer.md +1 -0
  9. package/agents/self-tester.md +260 -233
  10. package/agents/spec-generator.md +1 -0
  11. package/agents/test-tools/autotest/README.md +223 -0
  12. package/agents/test-tools/autotest/config.yaml.example +58 -0
  13. package/agents/test-tools/autotest/pyproject.toml +16 -0
  14. package/agents/test-tools/autotest/report_tool.py +759 -0
  15. package/agents/test-tools/autotest/self_test_runner.py +773 -0
  16. package/agents/test-tools/autotest/testcases_schema.md +143 -0
  17. package/agents/test-tools/autotest/testcases_tool.py +215 -0
  18. package/agents/test-tools/autotest/uv.lock +3156 -0
  19. package/agents/test-tools/harmony_autotest-0.1.0-py3-none-any.whl +0 -0
  20. package/agents/test-tools/hypium-6.1.0.210-py3-none-any.whl +0 -0
  21. package/agents/test-tools/hypium_mcp-0.6.5-py3-none-any.whl +0 -0
  22. package/agents/test-tools/xdevice-6.1.0.210-py3-none-any.whl +0 -0
  23. package/agents/test-tools/xdevice_devicetest-6.1.0.210-py3-none-any.whl +0 -0
  24. package/agents/test-tools/xdevice_ohos-6.1.0.210-py3-none-any.whl +0 -0
  25. package/dist/cli/config-store.js +27 -2
  26. package/dist/cli/config.js +17 -6
  27. package/dist/cli/index.js +3 -2
  28. package/dist/cli/init.js +135 -22
  29. package/dist/cli/mcp.js +2 -2
  30. package/dist/context/index.js +165 -69
  31. package/package.json +59 -60
  32. package/skills/code-dev-review-fix/SKILL.md +279 -0
  33. package/skills/code-dev-review-fix-workspace/evals/evals.json +56 -0
  34. package/skills/code-dev-review-fix-workspace/iteration-1/routing-results.md +23 -0
  35. package/skills/convert_pipeline/SKILL.md +423 -439
  36. package/skills/hmos-resources-convert/SKILL.md +623 -0
  37. package/skills/hmos-resources-convert/evals/evals.json +171 -0
  38. package/skills/hmos-resources-convert/references/conversion-rules.md +663 -0
  39. package/skills/hmos-resources-convert/references/dependency-analysis-rules.md +388 -0
  40. package/skills/hmos-resources-convert/references/resource-mapping-rules.md +457 -0
  41. package/skills/hmos-resources-convert/references/xml-drawable-to-svg-rules.md +513 -0
  42. package/skills/hmos-resources-convert/template/AppScope/app.json5 +10 -0
  43. package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +8 -0
  44. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/background.png +0 -0
  45. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/foreground.png +0 -0
  46. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/layered_image.json +7 -0
  47. package/skills/hmos-resources-convert/template/build-profile.json5 +42 -0
  48. package/skills/hmos-resources-convert/template/code-linter.json5 +32 -0
  49. package/skills/hmos-resources-convert/template/entry/build-profile.json5 +33 -0
  50. package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +6 -0
  51. package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +23 -0
  52. package/skills/hmos-resources-convert/template/entry/oh-package.json5 +10 -0
  53. package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +48 -0
  54. package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +16 -0
  55. package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +23 -0
  56. package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +55 -0
  57. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +8 -0
  58. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +8 -0
  59. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +16 -0
  60. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/background.png +0 -0
  61. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/foreground.png +0 -0
  62. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/layered_image.json +7 -0
  63. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/startIcon.png +0 -0
  64. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/backup_config.json +3 -0
  65. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +5 -0
  66. package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +8 -0
  67. package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +2 -0
  68. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +35 -0
  69. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +5 -0
  70. package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +16 -0
  71. package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +5 -0
  72. package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +33 -0
  73. package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +23 -0
  74. package/skills/hmos-resources-convert/template/hvigorfile.ts +6 -0
  75. package/skills/hmos-resources-convert/template/oh-package-lock.json5 +28 -0
  76. package/skills/hmos-resources-convert/template/oh-package.json5 +10 -0
  77. package/skills/hmos-resources-convert/tools/apktool.bat +85 -0
  78. package/skills/hmos-resources-convert/tools/apktool_3.0.1.jar +0 -0
  79. package/skills/hmos-ui-align/SKILL.md +182 -0
  80. package/skills/hmos-ui-align/config-example.json +11 -0
  81. package/skills/hmos-ui-align/config.json +11 -0
  82. package/skills/hmos-ui-align/diff_analysis.md +53 -0
  83. package/skills/hmos-ui-align/page_align.md +62 -0
  84. package/skills/hmos-ui-align/readme.md +231 -0
  85. package/skills/hmos-ui-align/references/Comparison_Template.md +2 -0
  86. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@Link/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/217/214/345/220/221/345/220/214/346/255/245.md +648 -0
  87. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@Observed/350/243/205/351/245/260/345/231/250/345/222/214@ObjectLink/350/243/205/351/245/260/345/231/250/357/274/232/345/265/214/345/245/227/347/261/273/345/257/271/350/261/241/345/261/236/346/200/247/345/217/230/345/214/226.md +2089 -0
  88. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@Prop/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/215/225/345/220/221/345/220/214/346/255/245.md +1033 -0
  89. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@Provide/350/243/205/351/245/260/345/231/250/345/222/214@Consume/350/243/205/351/245/260/345/231/250/357/274/232/344/270/216/345/220/216/344/273/243/347/273/204/344/273/266/345/217/214/345/220/221/345/220/214/346/255/245.md +1183 -0
  90. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@State/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/347/212/266/346/200/201.md +576 -0
  91. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@Track/350/243/205/351/245/260/345/231/250/357/274/232class/345/257/271/350/261/241/345/261/236/346/200/247/347/272/247/346/233/264/346/226/260.md +297 -0
  92. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/@Watch/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/346/233/264/346/224/271/351/200/232/347/237/245.md +395 -0
  93. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/AppStorage/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200/347/232/204UI/347/212/266/346/200/201/345/255/230/345/202/250.md +903 -0
  94. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/Environment/357/274/232/350/256/276/345/244/207/347/216/257/345/242/203/346/237/245/350/257/242.md +106 -0
  95. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/LocalStorage/357/274/232/351/241/265/351/235/242/347/272/247UI/347/212/266/346/200/201/345/255/230/345/202/250.md +1178 -0
  96. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/MVVM/346/250/241/345/274/217V1.md +911 -0
  97. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md +911 -0
  98. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/PersistentStorage/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md +355 -0
  99. package/skills/hmos-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243//347/256/241/347/220/206/345/272/224/347/224/250/346/213/245/346/234/211/347/232/204/347/212/266/346/200/201/346/246/202/350/277/260.md +11 -0
  100. package/skills/hmos-ui-align/references/UI_Analysis_Template.md +4 -0
  101. package/skills/hmos-ui-align/references/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +2535 -0
  102. package/skills/hmos-ui-align/references/android-to-harmonyOS-ui-interaction-mapping-reference.md +555 -0
  103. package/skills/hmos-ui-align/references/android-to-harmonyOS-ui-layout-mapping-reference.md +117 -0
  104. package/skills/hmos-ui-align/scripts/app_feature_verify.py +443 -0
  105. package/skills/hmos-ui-align/scripts/navigation-capure.md +37 -0
  106. package/skills/hmos-ui-align/scripts/page_capture.py +592 -0
  107. package/skills/hmos-ui-align-batch/SKILL.md +99 -0
  108. package/skills/hmos-ui-align-batch/references/conversion-procedure.md +180 -0
  109. package/skills/hmos-ui-align-batch/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +2535 -0
  110. package/skills/hmos-ui-align-batch/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +555 -0
  111. package/skills/hmos-ui-align-batch/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +117 -0
  112. package/skills/hmos-ui-align-batch/references/mvvm/@Link/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/217/214/345/220/221/345/220/214/346/255/245.md +648 -0
  113. package/skills/hmos-ui-align-batch/references/mvvm/@Observed/350/243/205/351/245/260/345/231/250/345/222/214@ObjectLink/350/243/205/351/245/260/345/231/250/357/274/232/345/265/214/345/245/227/347/261/273/345/257/271/350/261/241/345/261/236/346/200/247/345/217/230/345/214/226.md +2089 -0
  114. package/skills/hmos-ui-align-batch/references/mvvm/@Prop/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/215/225/345/220/221/345/220/214/346/255/245.md +1033 -0
  115. package/skills/hmos-ui-align-batch/references/mvvm/@Provide/350/243/205/351/245/260/345/231/250/345/222/214@Consume/350/243/205/351/245/260/345/231/250/357/274/232/344/270/216/345/220/216/344/273/243/347/273/204/344/273/266/345/217/214/345/220/221/345/220/214/346/255/245.md +1183 -0
  116. package/skills/hmos-ui-align-batch/references/mvvm/@State/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/347/212/266/346/200/201.md +576 -0
  117. package/skills/hmos-ui-align-batch/references/mvvm/@Track/350/243/205/351/245/260/345/231/250/357/274/232class/345/257/271/350/261/241/345/261/236/346/200/247/347/272/247/346/233/264/346/226/260.md +297 -0
  118. package/skills/hmos-ui-align-batch/references/mvvm/@Watch/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/346/233/264/346/224/271/351/200/232/347/237/245.md +395 -0
  119. package/skills/hmos-ui-align-batch/references/mvvm/AppStorage/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200/347/232/204UI/347/212/266/346/200/201/345/255/230/345/202/250.md +903 -0
  120. package/skills/hmos-ui-align-batch/references/mvvm/Environment/357/274/232/350/256/276/345/244/207/347/216/257/345/242/203/346/237/245/350/257/242.md +106 -0
  121. package/skills/hmos-ui-align-batch/references/mvvm/LocalStorage/357/274/232/351/241/265/351/235/242/347/272/247UI/347/212/266/346/200/201/345/255/230/345/202/250.md +1178 -0
  122. package/skills/hmos-ui-align-batch/references/mvvm/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md +911 -0
  123. package/skills/hmos-ui-align-batch/references/mvvm/PersistentStorage/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md +355 -0
  124. package/skills/hmos-ui-align-batch/references/mvvm//347/256/241/347/220/206/345/272/224/347/224/250/346/213/245/346/234/211/347/232/204/347/212/266/346/200/201/346/246/202/350/277/260.md +11 -0
  125. package/skills/hmos-ui-align-batch/scripts/android_parse_fast.py +1606 -0
  126. package/skills/self-test/SKILL.md +369 -0
  127. package/skills/self-test/readme.md +309 -0
  128. package/skills/spec-generator-skill/SKILL.md +332 -0
  129. package/skills/spec-generator-skill/references/android-platform-tokens.md +105 -0
  130. package/skills/spec-generator-skill/references/spec-sample-1.md +78 -0
  131. package/skills/spec-generator-skill/references/spec-sample-2.md +58 -0
  132. package/skills/spec-generator-skill/references/spec-sample-3.md +116 -0
  133. package/skills/spec-generator-skill/references/step4-report-template.md +33 -0
  134. package/agents/self-test-setup.md +0 -165
  135. package/dist/context/resources/sdkConfig.json +0 -24
  136. package/src/context/resources/sdkConfig.json +0 -24
@@ -0,0 +1,759 @@
1
+ #!/usr/bin/env python3
2
+ """Generate or validate the self-test-report.md.
3
+
4
+ Subcommands:
5
+ generate --task-subdir ... --app-metadata ... --hap ... --device ... \\
6
+ --suite ... --out ... [--validate]
7
+ Render `self-test-report.md` from a task_<ts>/ directory using a strict
8
+ markdown template; optionally re-validate the output in-process.
9
+
10
+ validate <report-path>
11
+ Check that an existing `self-test-report.md` conforms to the required
12
+ layout (sections, per-case fields, contiguous Pre/Case indices, etc.).
13
+ Exit 0 = PASSED, exit 1 = FAILED.
14
+
15
+ Strategy:
16
+ - Read summary.json + task_results.jsonl from <task-subdir>.
17
+ - For each case: open <report_dir>/<date>/<HHMMSS>/<HHMMSS>.json and extract the
18
+ `<judgment>` block from the final `task_end` event. PASS / FAIL cases always
19
+ have this block (it is written by the AutoTest planner agent itself).
20
+ - For UNKNOWN cases (no judgment block — planner exhausted its step budget),
21
+ fall back to JSONL `reason` plus the last `planner_thinking` event content.
22
+ - Pre-cases (`case_name` starts with `[PRE] `) render in `## 前置用例` with
23
+ `### Pre N: <name without prefix>`; regular cases render in `## 用例详情`
24
+ with `### Case N: <name>`.
25
+ """
26
+
27
+ import argparse
28
+ import io
29
+ import json
30
+ import os
31
+ import re
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ SCRIPT_DIR = Path(__file__).resolve().parent
36
+
37
+ PRE_PREFIX = "[PRE] "
38
+
39
+
40
+ # =============================================================================
41
+ # Parsers
42
+ # =============================================================================
43
+
44
+ def parse_judgment_block(content: str) -> dict:
45
+ """Parse <judgment>...</judgment> from a task_end event.
46
+
47
+ Returns a dict with whatever headers were found (e.g., 预期验证, 历史回顾,
48
+ 状态确认, Bug发现, 判定结果). Returns empty dict if no judgment block.
49
+ """
50
+ m = re.search(r"<judgment>(.*?)</judgment>", content, re.DOTALL)
51
+ if not m:
52
+ return {}
53
+
54
+ body = m.group(1).strip()
55
+ # Header form: **<chinese-word>**[::] then content until next **header** or end.
56
+ parts = re.split(r"\*\*([\u4e00-\u9fffA-Za-z]+)\*\*\s*[::]\s*", body)
57
+ # parts[0] is preamble before first header; parts[1::2] = headers; parts[2::2] = bodies
58
+ sections: dict[str, str] = {}
59
+ for i in range(1, len(parts), 2):
60
+ header = parts[i].strip()
61
+ text = parts[i + 1].strip() if i + 1 < len(parts) else ""
62
+ sections[header] = text
63
+ return sections
64
+
65
+
66
+ def parse_test_steps(test_steps: str) -> tuple[list[str], str]:
67
+ """Parse `动作:... -> ...\\n预期结果:...` into (action_list, expected_text)."""
68
+ actions_match = re.search(r"动作[::]\s*(.+?)(?=\n\s*预期结果[::]|\Z)", test_steps, re.DOTALL)
69
+ expected_match = re.search(r"预期结果[::]\s*(.+)", test_steps, re.DOTALL)
70
+
71
+ if actions_match:
72
+ actions_raw = actions_match.group(1).strip()
73
+ action_list = [s.strip() for s in re.split(r"\s*->\s*|\s*→\s*", actions_raw) if s.strip()]
74
+ else:
75
+ action_list = [test_steps.strip()] if test_steps.strip() else []
76
+
77
+ expected = expected_match.group(1).strip() if expected_match else ""
78
+ return action_list, expected
79
+
80
+
81
+ def find_latest_task_json(report_dir: Path) -> Path | None:
82
+ """Locate <report_dir>/<YYYY-MM-DD>/<HH-MM-SS>/<HH-MM-SS>.json (the structured run log)."""
83
+ if not report_dir.exists():
84
+ return None
85
+
86
+ date_pat = re.compile(r"^\d{4}-\d{2}-\d{2}$")
87
+ time_pat = re.compile(r"^\d{2}-\d{2}-\d{2}$")
88
+
89
+ date_dirs = sorted(d for d in report_dir.iterdir() if d.is_dir() and date_pat.match(d.name))
90
+ if not date_dirs:
91
+ return None
92
+
93
+ for date_dir in reversed(date_dirs):
94
+ time_dirs = sorted(d for d in date_dir.iterdir() if d.is_dir() and time_pat.match(d.name))
95
+ if not time_dirs:
96
+ continue
97
+ for time_dir in reversed(time_dirs):
98
+ candidate = time_dir / f"{time_dir.name}.json"
99
+ if candidate.exists():
100
+ return candidate
101
+ return None
102
+
103
+
104
+ def extract_last_thinking(events: list[dict]) -> str:
105
+ """Return content of last planner_thinking event."""
106
+ for ev in reversed(events):
107
+ if ev.get("event_type") == "planner_thinking":
108
+ return ev.get("content", "").strip()
109
+ return ""
110
+
111
+
112
+ def load_case_judgment(report_dir: str) -> tuple[dict, str]:
113
+ """Load the judgment block + last thinking for one case.
114
+
115
+ Returns (judgment_dict, last_thinking_text). Either may be empty.
116
+ """
117
+ path = find_latest_task_json(Path(report_dir))
118
+ if path is None:
119
+ return {}, ""
120
+
121
+ try:
122
+ with open(path, "r", encoding="utf-8") as f:
123
+ data = json.load(f)
124
+ except (OSError, json.JSONDecodeError):
125
+ return {}, ""
126
+
127
+ events = data.get("events", []) or []
128
+ if not events:
129
+ return {}, ""
130
+
131
+ # task_end is the last event for completed cases; for UNKNOWN the agent may
132
+ # not have emitted task_end at all but the last planner_thinking is still useful.
133
+ task_end_content = ""
134
+ for ev in reversed(events):
135
+ if ev.get("event_type") == "task_end":
136
+ task_end_content = ev.get("content", "")
137
+ break
138
+
139
+ judgment = parse_judgment_block(task_end_content) if task_end_content else {}
140
+ last_thinking = extract_last_thinking(events)
141
+ return judgment, last_thinking
142
+
143
+
144
+ # =============================================================================
145
+ # Renderers
146
+ # =============================================================================
147
+
148
+ def render_overview(suite: str, time_range: str, device: str, app_name: str,
149
+ bundle_name: str, hap: str, totals: dict) -> str:
150
+ pre_total = totals["pre_total"]
151
+ reg_total = totals["regular_total"]
152
+ total = pre_total + reg_total
153
+ pass_count = totals["pass"]
154
+ fail_count = totals["fail"]
155
+ unknown_count = totals["unknown"]
156
+ not_passed = fail_count + unknown_count
157
+
158
+ primary_rate = totals["regular_pass_rate"]
159
+ mixed_rate = totals["pass_rate"]
160
+
161
+ lines = [
162
+ "## 测试概览",
163
+ "",
164
+ f"- **测试套件**: {suite}",
165
+ f"- **测试时间**: {time_range}",
166
+ f"- **设备**: {device}",
167
+ f"- **应用**: {app_name} ({bundle_name})",
168
+ f"- **HAP**: {hap}",
169
+ f"- **总用例数**: {total}(前置 {pre_total} + 常规 {reg_total})",
170
+ f"- **通过**: {pass_count}(前置 {totals['pre_pass']} / 常规 {totals['regular_pass']})",
171
+ f"- **失败**: {not_passed}(FAIL {fail_count} + UNKNOWN {unknown_count})",
172
+ f"- **常规通过率**: {primary_rate}(仅功能场景,反映本次需求质量)",
173
+ f"- **含前置通过率**: {mixed_rate}(仅供整体参考;前置用例属于数据/环境准备,与本次需求功能无关)",
174
+ "",
175
+ ]
176
+ return "\n".join(lines)
177
+
178
+
179
+ def render_case_block(idx: int, prefix: str, case: dict) -> str:
180
+ """Render a single Pre N / Case N block."""
181
+ case_name = case["case_name"]
182
+ if prefix == "Pre":
183
+ title = case_name[len(PRE_PREFIX):] if case_name.startswith(PRE_PREFIX) else case_name
184
+ else:
185
+ title = case_name
186
+
187
+ status = case["status"]
188
+ duration = case.get("duration_seconds", 0)
189
+ report_dir = case.get("report_dir", "")
190
+ test_steps = case.get("test_steps", "")
191
+ reason = (case.get("reason") or "").strip()
192
+ judgment = case.get("_judgment") or {}
193
+ last_thinking = case.get("_last_thinking") or ""
194
+
195
+ actions, expected = parse_test_steps(test_steps)
196
+
197
+ # 操作执行 — derived from judgment / status
198
+ state_conf = judgment.get("状态确认", "").strip()
199
+ expected_match = judgment.get("预期验证", "").strip()
200
+ bug_found = judgment.get("Bug发现", "").strip()
201
+
202
+ if status == "PASS":
203
+ op_exec = f"成功 — {expected_match}" if expected_match else "成功 — AutoTest 判定通过"
204
+ elif status == "FAIL":
205
+ if bug_found:
206
+ op_exec = f"失败 — {bug_found.splitlines()[0][:200]}"
207
+ elif state_conf:
208
+ op_exec = f"失败 — {state_conf.splitlines()[0][:200]}"
209
+ else:
210
+ op_exec = f"失败 — {reason or 'AutoTest 判定不通过'}"
211
+ else: # UNKNOWN
212
+ op_exec = f"未完成 — {reason or 'AutoTest 未能给出确定结论'}"
213
+
214
+ lines = [f"### {prefix} {idx}: {title}", ""]
215
+ lines.append(f"- **执行结果**: {status}")
216
+ lines.append("- **操作步骤**:")
217
+ if actions:
218
+ for i, a in enumerate(actions, 1):
219
+ lines.append(f" {i}. {a}")
220
+ else:
221
+ lines.append(" 1. (未提供动作步骤)")
222
+ lines.append(f"- **期望结果**: {expected or '(未提供期望结果)'}")
223
+ lines.append(f"- **操作执行**: {op_exec}")
224
+ lines.append(f"- **AutoTest 任务路径**: `{report_dir}`")
225
+ lines.append("- **AutoTest 详情**:")
226
+ lines.append(f" - AutoTest 结果: {status}")
227
+
228
+ if judgment:
229
+ # Dump every section the planner emitted, in stable order if known.
230
+ # 失败原因 is the downstream-facing alias of "Bug发现" — emit it instead of
231
+ # Bug发现 to avoid duplication and satisfy the validator's literal check.
232
+ ordered_keys = ["预期验证", "历史回顾", "状态确认", "判定结果"]
233
+ seen = {"Bug发现"} # intentionally skipped — surfaced as 失败原因 below
234
+ for key in ordered_keys:
235
+ if key in judgment:
236
+ lines.extend(_emit_judgment_bullet(key, judgment[key]))
237
+ seen.add(key)
238
+ for key, value in judgment.items():
239
+ if key not in seen:
240
+ lines.extend(_emit_judgment_bullet(key, value))
241
+ if status == "PASS":
242
+ lines.append(" - 失败原因: 无")
243
+ else:
244
+ bug = judgment.get("Bug发现") or reason or "未知"
245
+ lines.extend(_emit_judgment_bullet("失败原因", bug))
246
+ else:
247
+ # No judgment block (typical for UNKNOWN). Use JSONL reason + last thinking snippet.
248
+ lines.append(f" - 失败原因: {reason or '未知'}")
249
+ if last_thinking:
250
+ snippet = last_thinking.replace("\n", " ")
251
+ snippet = (snippet[:400] + "…") if len(snippet) > 400 else snippet
252
+ lines.append(f" - 末态思考摘要: {snippet}")
253
+
254
+ lines.append(f" - 耗时: {duration} 秒")
255
+
256
+ # UNKNOWN-specific guidance for the fixer agent — must follow the AutoTest 详情
257
+ # block so the fixer reads it as part of the case context.
258
+ if status == "UNKNOWN":
259
+ lines.append("- **⚠️ UNKNOWN 处理规则(fixer 必读)**:")
260
+ lines.append(
261
+ " - 本用例 AutoTest agent **未到达验证步骤**(步数预算耗尽 / 超时 / 异常终止),"
262
+ "真实通过状态未知,**不等价于 FAIL**。"
263
+ )
264
+ lines.append(
265
+ " - 即使白盒审查在相邻代码中发现瑕疵,也**不得**把本用例算作 confirmed → 已修复。"
266
+ "原始 UNKNOWN 的根因(agent 超时)不会因任何代码改动消失,"
267
+ "必须在 AutoTest 端加大步数上限/人工复测后才能定论。"
268
+ )
269
+ lines.append(
270
+ " - 处理建议:在 fixer 白盒审查阶段将本用例归类为 `unknown_no_verdict` —— "
271
+ "记录"
272
+ "「待重跑确认」,**不计入本轮修复成果**,也不消耗 fixer 的有效尝试预算。"
273
+ )
274
+
275
+ lines.append("")
276
+ return "\n".join(lines)
277
+
278
+
279
+ def _one_line(s: str) -> str:
280
+ """Compact a multi-line judgment value into a single line for the report bullet."""
281
+ return re.sub(r"\s+", " ", s).strip()
282
+
283
+
284
+ def _emit_judgment_bullet(key: str, value: str) -> list[str]:
285
+ """Render `- key: value` under 'AutoTest 详情'.
286
+
287
+ If value has multiple non-empty lines or contains list-like markers, render
288
+ as `- key:\\n <multi-line, preserved>` so nested bullets stay readable.
289
+ Otherwise compact to a single inline line.
290
+ """
291
+ text = (value or "").strip()
292
+ raw_lines = [ln for ln in text.splitlines() if ln.strip()]
293
+ if len(raw_lines) <= 1:
294
+ return [f" - {key}: {_one_line(text) or '(空)'}"]
295
+
296
+ out = [f" - {key}:"]
297
+ for ln in raw_lines:
298
+ out.append(f" {ln.rstrip()}")
299
+ return out
300
+
301
+
302
+ def render_summary(cases: list[dict], totals: dict) -> str:
303
+ pre_total = totals["pre_total"]
304
+ reg_total = totals["regular_total"]
305
+ total = pre_total + reg_total
306
+ pass_count = totals["pass"]
307
+ fail_count = totals["fail"]
308
+ unknown_count = totals["unknown"]
309
+ not_passed = fail_count + unknown_count
310
+
311
+ lines = [
312
+ "## 测试总结",
313
+ "",
314
+ f"- **总计用例**: {total}(PRE {pre_total} + Regular {reg_total})",
315
+ f"- **常规用例(功能场景)**: {totals['regular_pass']}/{reg_total} 通过,**通过率 {totals['regular_pass_rate']}**(反映本次需求质量)",
316
+ f"- **前置用例(数据/环境准备)**: {totals['pre_pass']}/{pre_total} 通过(与本次功能需求无关,仅作环境就绪信号)",
317
+ f"- **总计通过**: {pass_count}",
318
+ f"- **总计未通过**: {not_passed}(FAIL {fail_count} + UNKNOWN {unknown_count})",
319
+ f"- **含前置通过率**: {totals['pass_rate']}(不作为功能质量指标)",
320
+ "",
321
+ ]
322
+
323
+ not_passed_cases = [c for c in cases if c["status"] != "PASS"]
324
+ if not_passed_cases:
325
+ lines.append("### 未通过用例列表")
326
+ lines.append("")
327
+ lines.append("| # | 类别 | 用例描述 | 失败类型 | 失败原因 |")
328
+ lines.append("|---|------|---------|---------|---------|")
329
+ for i, c in enumerate(not_passed_cases, 1):
330
+ category = "前置" if c["case_name"].startswith(PRE_PREFIX) else "常规"
331
+ name = c["case_name"][len(PRE_PREFIX):] if c["case_name"].startswith(PRE_PREFIX) else c["case_name"]
332
+ j = c.get("_judgment") or {}
333
+ bug = (j.get("Bug发现") or "").strip()
334
+ reason = (c.get("reason") or "").strip()
335
+ fail_reason = _one_line(bug or reason or "未知")
336
+ if len(fail_reason) > 200:
337
+ fail_reason = fail_reason[:200] + "…"
338
+ lines.append(f"| {i} | {category} | {name} | {c['status']} | {fail_reason} |")
339
+ lines.append("")
340
+
341
+ lines.append("### 建议")
342
+ lines.append("")
343
+ suggestions = build_suggestions(cases, totals)
344
+ for s in suggestions:
345
+ lines.append(f"- {s}")
346
+ if not suggestions:
347
+ lines.append("- 全部用例通过,无需进一步动作。")
348
+ lines.append("")
349
+ return "\n".join(lines)
350
+
351
+
352
+ # Failure-reason keywords that suggest a regular case failed because of unmet
353
+ # test preconditions rather than a genuine app defect. When pre-cases also
354
+ # failed, these are likely cascade failures and should NOT be treated as app
355
+ # bugs by the fixer.
356
+ _CASCADE_PATTERNS = (
357
+ "测试环境",
358
+ "环境条件",
359
+ "环境异常",
360
+ "前置",
361
+ "没有音乐", "无音乐",
362
+ "没有文件", "无文件",
363
+ "无可用",
364
+ "库为空", "歌曲库为空",
365
+ "数据缺失", "数据不足",
366
+ "no files", "no music", "library is empty",
367
+ )
368
+
369
+
370
+ def _cascade_text(case: dict) -> str:
371
+ """Concatenate the strings the cascade heuristic should scan over.
372
+
373
+ Covers both the judgment block (normal completion) AND the last
374
+ planner_thinking event (abnormal termination — judgment may be absent
375
+ when the planner exits early on environment errors).
376
+ """
377
+ j = case.get("_judgment") or {}
378
+ return " ".join([
379
+ case.get("reason") or "",
380
+ j.get("Bug发现") or "",
381
+ j.get("状态确认") or "",
382
+ j.get("历史回顾") or "",
383
+ case.get("_last_thinking") or "",
384
+ ]).lower()
385
+
386
+
387
+ def _is_cascade_failure(case: dict) -> bool:
388
+ text = _cascade_text(case)
389
+ return any(p.lower() in text for p in _CASCADE_PATTERNS)
390
+
391
+
392
+ def build_suggestions(cases: list[dict], totals: dict) -> list[str]:
393
+ """Compose templated suggestions based on failure categories — no LLM needed."""
394
+ out: list[str] = []
395
+ pre_fails = [c for c in cases if c["case_name"].startswith(PRE_PREFIX) and c["status"] != "PASS"]
396
+ reg_non_pass = [c for c in cases if not c["case_name"].startswith(PRE_PREFIX) and c["status"] != "PASS"]
397
+ reg_unknowns = [c for c in reg_non_pass if c["status"] == "UNKNOWN"]
398
+ reg_fails = [c for c in reg_non_pass if c["status"] == "FAIL"]
399
+
400
+ # Cascade detection only makes sense when pre-cases also failed.
401
+ cascade_cases: list[dict] = []
402
+ if pre_fails:
403
+ cascade_cases = [c for c in reg_non_pass if _is_cascade_failure(c)]
404
+
405
+ if pre_fails:
406
+ names = ", ".join(c["case_name"][len(PRE_PREFIX):][:30] for c in pre_fails)
407
+ out.append(
408
+ f"**前置用例失败**({len(pre_fails)} 条:{names}):**前置用例不是本次需求功能的测试**,"
409
+ "它们只是为常规用例准备数据/环境(如导入媒体、授予权限、跳过引导)。失败 90% 以上是"
410
+ "测试环境/数据问题(缺素材、设备未授权、文件选择器交互异常等),**不应当作应用缺陷处理**。"
411
+ "建议:人工补齐前置数据后重跑受影响的常规用例;只有当白盒审查确认前置流程触发的是真实代码 Bug 时,"
412
+ "才进入 self-test-fixer 修复流。"
413
+ )
414
+
415
+ if cascade_cases:
416
+ cascade_names = ", ".join(
417
+ (c["case_name"][len(PRE_PREFIX):] if c["case_name"].startswith(PRE_PREFIX) else c["case_name"])[:50]
418
+ for c in cascade_cases
419
+ )
420
+ out.append(
421
+ f"**⚠️ {len(cascade_cases)} 条常规用例疑似前置连带失败**({cascade_names}):"
422
+ "失败原因中出现「测试环境/环境条件/没有音乐文件/前置不满足」等关键词,"
423
+ "说明这些用例 **可能不是独立的功能 Bug,而是 PRE 失败的传导**——"
424
+ "在前置数据未准备好的前提下,这些用例根本没机会跑到验证步骤。"
425
+ "**修复优先级**:先补齐前置 → 重跑这些用例 → 仍 FAIL 才认定为真实应用缺陷再进入修复流。"
426
+ "self-test-fixer 收到此类 case 应在白盒审查时严格甄别,避免给「环境不就绪」打补丁。"
427
+ )
428
+
429
+ if reg_unknowns:
430
+ unknown_names = ", ".join(
431
+ (c["case_name"][len(PRE_PREFIX):] if c["case_name"].startswith(PRE_PREFIX) else c["case_name"])[:50]
432
+ for c in reg_unknowns
433
+ )
434
+ out.append(
435
+ f"**{len(reg_unknowns)} 条 UNKNOWN**({unknown_names}):"
436
+ "AutoTest agent 因步数预算耗尽 / 超时未达验证阶段,**这些用例的真实通过状态未知**,"
437
+ "与 FAIL 性质不同。**fixer agent 不应把 UNKNOWN 算作「已修复」**——"
438
+ "即使白盒能找出顺手可修的瑕疵,原始 UNKNOWN 的根因(agent 超时)不会因代码改动消失,"
439
+ "必须在 AutoTest 端加大步数上限或人工复测后才能定论。"
440
+ "建议:将 UNKNOWN 案例从本轮修复目标中排除,仅作「待重跑候选」。"
441
+ )
442
+
443
+ # Show "real" regular fails — those that are NOT obvious cascade failures.
444
+ real_reg_fails = [c for c in reg_fails if c not in cascade_cases]
445
+ if real_reg_fails:
446
+ out.append(
447
+ f"**{len(real_reg_fails)} 条疑似真实常规 FAIL**(已排除前置连带):交由 `self-test-fixer` agent "
448
+ "白盒审查后修复,或人工复核失败用例对应的 HarmonyOS 代码与 Android 参考实现。"
449
+ )
450
+ elif reg_fails and not real_reg_fails:
451
+ out.append(
452
+ "本次所有常规 FAIL 均疑似前置连带,**预计无需修改应用代码**。"
453
+ "请优先把前置流程跑通,再用同一批用例复跑确认。"
454
+ )
455
+
456
+ return out
457
+
458
+
459
+ # =============================================================================
460
+ # Validator (in-process; replaces the former validate_self_test_report.py)
461
+ # =============================================================================
462
+
463
+ REQUIRED_OVERVIEW_FIELDS = (
464
+ "测试套件", "测试时间", "设备", "应用", "HAP", "总用例数", "通过", "失败",
465
+ )
466
+ REQUIRED_CASE_FIELDS = (
467
+ "执行结果", "操作步骤", "期望结果", "AutoTest 任务路径", "AutoTest 详情",
468
+ )
469
+ REQUIRED_SECTIONS = ("测试概览", "前置用例", "用例详情", "测试总结")
470
+ # Sections under which `### ` headings carry case blocks and must match a strict prefix.
471
+ CASE_SECTIONS = (("前置用例", "Pre"), ("用例详情", "Case"))
472
+
473
+
474
+ def _extract_md_section(text: str, section_title: str) -> str | None:
475
+ """Return the body of `## <section_title>` up to the next `## ` (exclusive), or None."""
476
+ m = re.search(
477
+ rf"##\s*{re.escape(section_title)}\s*\n(.*?)(?=\n##\s|\Z)",
478
+ text,
479
+ re.DOTALL,
480
+ )
481
+ return m.group(1) if m else None
482
+
483
+
484
+ def _validate_case_section(section_body: str, section_title: str,
485
+ expected_prefix: str) -> tuple[list[tuple[str, str]], list[str]]:
486
+ """Validate one of the case sections.
487
+
488
+ Returns (blocks, errors):
489
+ - blocks: list of (heading_text, block_body) for downstream field checks.
490
+ - errors: messages collected during heading scanning.
491
+ """
492
+ errors: list[str] = []
493
+
494
+ chunks = re.split(r"(?m)^###\s+", section_body)
495
+ # chunks[0] = preamble (e.g., the note line + optional `_无前置用例_`); chunks[1:] = headed blocks.
496
+ blocks: list[tuple[str, str]] = []
497
+ for chunk in chunks[1:]:
498
+ line_end = chunk.find("\n")
499
+ if line_end == -1:
500
+ heading, body = chunk.strip(), ""
501
+ else:
502
+ heading, body = chunk[:line_end].strip(), chunk[line_end + 1:]
503
+ blocks.append((heading, body))
504
+
505
+ # Empty pre-section is acceptable (no pre-cases). Empty regular section is not.
506
+ if not blocks and expected_prefix == "Case":
507
+ errors.append(
508
+ f"Section '## {section_title}': no '### Case <N>:' blocks found — "
509
+ "report may be using a table-only format without per-case detail sections"
510
+ )
511
+
512
+ pattern = re.compile(rf"^{re.escape(expected_prefix)}\s+(\d+):\s")
513
+ indices: list[int] = []
514
+ for heading, _body in blocks:
515
+ m = pattern.match(heading)
516
+ if not m:
517
+ errors.append(
518
+ f"Section '## {section_title}': heading does NOT match "
519
+ f"`### {expected_prefix} <N>: <title>` — got `### {heading}`"
520
+ )
521
+ else:
522
+ indices.append(int(m.group(1)))
523
+
524
+ if indices:
525
+ expected_indices = list(range(1, len(indices) + 1))
526
+ if indices != expected_indices:
527
+ errors.append(
528
+ f"Section '## {section_title}': {expected_prefix} indices not "
529
+ f"contiguous from 1; expected {expected_indices}, got {indices}"
530
+ )
531
+
532
+ return blocks, errors
533
+
534
+
535
+ def validate_report(report_path: str) -> list[str]:
536
+ """Return a list of validation errors for self-test-report.md. Empty == PASSED."""
537
+ path = Path(report_path)
538
+ if not path.exists():
539
+ return [f"File not found: {report_path}"]
540
+
541
+ text = path.read_text(encoding="utf-8")
542
+ errors: list[str] = []
543
+
544
+ for section in REQUIRED_SECTIONS:
545
+ if section not in text:
546
+ errors.append(f"Missing section: '{section}'")
547
+
548
+ overview = _extract_md_section(text, "测试概览")
549
+ if overview is None:
550
+ errors.append("Cannot locate '## 测试概览' section")
551
+ else:
552
+ for field in REQUIRED_OVERVIEW_FIELDS:
553
+ if field not in overview:
554
+ errors.append(f"Missing overview field: '{field}'")
555
+
556
+ all_blocks: list[tuple[str, str, str]] = [] # (section_title, heading, body)
557
+ for section_title, expected_prefix in CASE_SECTIONS:
558
+ body = _extract_md_section(text, section_title)
559
+ if body is None:
560
+ continue
561
+ blocks, section_errors = _validate_case_section(body, section_title, expected_prefix)
562
+ errors.extend(section_errors)
563
+ for heading, block_body in blocks:
564
+ all_blocks.append((section_title, heading, block_body))
565
+
566
+ for section_title, heading, body in all_blocks:
567
+ missing = [f for f in REQUIRED_CASE_FIELDS if f not in body]
568
+ if missing:
569
+ label = heading[:60] + ("…" if len(heading) > 60 else "")
570
+ errors.append(
571
+ f"Section '## {section_title}', block `### {label}`: missing fields: {missing}"
572
+ )
573
+
574
+ has_failures = re.search(r"\*\*失败\*\*:\s*([1-9])", text)
575
+ if has_failures:
576
+ if "未通过用例列表" not in text:
577
+ errors.append("Report has failures but missing '未通过用例列表' section")
578
+
579
+ for section_title, heading, body in all_blocks:
580
+ if re.search(r"\*\*执行结果\*\*:\s*FAIL", body, re.IGNORECASE):
581
+ detail = body.split("AutoTest 详情", 1)
582
+ detail_part = detail[1] if len(detail) > 1 else body
583
+ if "失败原因" not in detail_part and "失败" not in detail_part:
584
+ label = heading[:60] + ("…" if len(heading) > 60 else "")
585
+ errors.append(
586
+ f"Section '## {section_title}', block `### {label}`: "
587
+ f"FAIL but missing failure reason in 'AutoTest 详情'"
588
+ )
589
+
590
+ return errors
591
+
592
+
593
+ # =============================================================================
594
+ # Pipeline (generate)
595
+ # =============================================================================
596
+
597
+ def render_report(task_subdir: Path, app_metadata: Path, hap: str, device: str,
598
+ suite: str) -> str:
599
+ """Compose the complete self-test-report.md text from task_<ts>/ data."""
600
+ summary_path = task_subdir / "summary.json"
601
+ jsonl_path = task_subdir / "task_results.jsonl"
602
+ for p in (summary_path, jsonl_path):
603
+ if not p.exists():
604
+ raise FileNotFoundError(f"missing file in task-subdir: {p}")
605
+
606
+ summary = json.loads(summary_path.read_text(encoding="utf-8"))
607
+
608
+ cases: list[dict] = []
609
+ for line in jsonl_path.read_text(encoding="utf-8").splitlines():
610
+ line = line.strip()
611
+ if not line:
612
+ continue
613
+ case = json.loads(line)
614
+ report_dir = case.get("report_dir") or ""
615
+ judgment, last_thinking = load_case_judgment(report_dir) if report_dir else ({}, "")
616
+ case["_judgment"] = judgment
617
+ case["_last_thinking"] = last_thinking
618
+ cases.append(case)
619
+
620
+ pre_cases = [c for c in cases if c["case_name"].startswith(PRE_PREFIX)]
621
+ reg_cases = [c for c in cases if not c["case_name"].startswith(PRE_PREFIX)]
622
+
623
+ def count(seq, status):
624
+ return sum(1 for c in seq if c["status"] == status)
625
+
626
+ pre_pass = count(pre_cases, "PASS")
627
+ regular_pass = count(reg_cases, "PASS")
628
+ regular_rate = f"{(regular_pass / len(reg_cases) * 100):.2f}%" if reg_cases else "N/A"
629
+ totals = {
630
+ "pre_total": len(pre_cases),
631
+ "regular_total": len(reg_cases),
632
+ "pre_pass": pre_pass,
633
+ "regular_pass": regular_pass,
634
+ "pass": summary.get("pass_count", count(cases, "PASS")),
635
+ "fail": summary.get("fail_count", count(cases, "FAIL")),
636
+ "unknown": summary.get("unknown_count", count(cases, "UNKNOWN")),
637
+ "pass_rate": summary.get("pass_rate", "0%"),
638
+ "regular_pass_rate": regular_rate,
639
+ }
640
+
641
+ meta = json.loads(app_metadata.read_text(encoding="utf-8"))
642
+ bundle_name = meta.get("bundle_name", "")
643
+ app_name = meta.get("app_name", "")
644
+
645
+ start_time = summary.get("start_time", "")
646
+ end_time = summary.get("end_time", "")
647
+ time_range = f"{start_time} ~ {end_time}" if start_time and end_time else (start_time or "未知")
648
+
649
+ hap_basename = os.path.basename(hap)
650
+
651
+ parts = [
652
+ "# Self-Test 测试报告",
653
+ "",
654
+ render_overview(suite, time_range, device, app_name, bundle_name, hap_basename, totals),
655
+ "---",
656
+ "",
657
+ "## 前置用例",
658
+ "",
659
+ ]
660
+ if pre_cases:
661
+ parts.append(
662
+ "> **前置用例是为常规用例准备数据/环境的设置脚本(来自 `pre_test_case.md`),"
663
+ "**不属于本次需求功能本身**。此处的 PASS/FAIL 仅反映测试前置条件是否就绪:"
664
+ "FAIL 通常意味着测试环境问题(如缺少素材、权限未授予、首启引导未跳过),"
665
+ "并非应用功能缺陷。但需注意——前置失败可能让后续依赖该数据的常规用例无效,"
666
+ "判读常规结果时应将受影响的用例排除或在补齐前置后重跑。"
667
+ )
668
+ parts.append("")
669
+ for i, case in enumerate(pre_cases, 1):
670
+ parts.append(render_case_block(i, "Pre", case))
671
+ else:
672
+ parts.append("_无前置用例_")
673
+ parts.append("")
674
+
675
+ parts.extend(["---", "", "## 用例详情", ""])
676
+ for i, case in enumerate(reg_cases, 1):
677
+ parts.append(render_case_block(i, "Case", case))
678
+
679
+ parts.extend(["---", "", render_summary(cases, totals)])
680
+ return "\n".join(parts)
681
+
682
+
683
+ def _print_validation(errors: list[str], report_path: Path) -> int:
684
+ if not errors:
685
+ print(f"VALIDATION PASSED: {report_path}")
686
+ return 0
687
+ print(f"VALIDATION FAILED: {len(errors)} issue(s) in {report_path}")
688
+ for e in errors:
689
+ print(f" - {e}")
690
+ return 1
691
+
692
+
693
+ # =============================================================================
694
+ # CLI
695
+ # =============================================================================
696
+
697
+ def cmd_generate(args) -> None:
698
+ task_subdir: Path = args.task_subdir.resolve()
699
+ if not task_subdir.exists():
700
+ print(f"ERROR: task-subdir not found: {task_subdir}", file=sys.stderr)
701
+ sys.exit(2)
702
+
703
+ try:
704
+ report_text = render_report(
705
+ task_subdir=task_subdir,
706
+ app_metadata=args.app_metadata,
707
+ hap=args.hap,
708
+ device=args.device,
709
+ suite=args.suite,
710
+ )
711
+ except FileNotFoundError as e:
712
+ print(f"ERROR: {e}", file=sys.stderr)
713
+ sys.exit(2)
714
+
715
+ out_path: Path = args.out.resolve()
716
+ out_path.parent.mkdir(parents=True, exist_ok=True)
717
+ out_path.write_text(report_text, encoding="utf-8")
718
+ print(f"Generated: {out_path}")
719
+
720
+ if args.validate:
721
+ sys.exit(_print_validation(validate_report(str(out_path)), out_path))
722
+
723
+
724
+ def cmd_validate(args) -> None:
725
+ path = Path(args.path).resolve()
726
+ sys.exit(_print_validation(validate_report(str(path)), path))
727
+
728
+
729
+ def main():
730
+ if sys.platform == "win32":
731
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
732
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
733
+
734
+ parser = argparse.ArgumentParser(
735
+ description="Generate or validate self-test-report.md."
736
+ )
737
+ sub = parser.add_subparsers(dest="command", required=True)
738
+
739
+ p_gen = sub.add_parser("generate", help="Render self-test-report.md from a task_<ts>/ directory")
740
+ p_gen.add_argument("--task-subdir", required=True, type=Path)
741
+ p_gen.add_argument("--app-metadata", required=True, type=Path)
742
+ p_gen.add_argument("--hap", required=True)
743
+ p_gen.add_argument("--device", required=True)
744
+ p_gen.add_argument("--suite", required=True)
745
+ p_gen.add_argument("--out", required=True, type=Path)
746
+ p_gen.add_argument("--validate", action="store_true",
747
+ help="After writing, re-validate the report in-process")
748
+ p_gen.set_defaults(func=cmd_generate)
749
+
750
+ p_val = sub.add_parser("validate", help="Validate an existing self-test-report.md")
751
+ p_val.add_argument("path", help="Path to the self-test-report.md to validate")
752
+ p_val.set_defaults(func=cmd_validate)
753
+
754
+ args = parser.parse_args()
755
+ args.func(args)
756
+
757
+
758
+ if __name__ == "__main__":
759
+ main()