magic-agent 2.4.3 → 2.6.3

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 (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -0,0 +1,387 @@
1
+ package requirements
2
+
3
+ // shots_test.go - 「页面完成情况」截图的落盘、文档追加与往返保持。
4
+ //
5
+ // 重点盯三件事:
6
+ // · 追加截图**不动**用户手改过的内容(只加两行,不整篇重渲染);
7
+ // · 归纳重写文档时**保住**截图段(否则用户攒的完成情况会被抹掉);
8
+ // · 落盘路径是**正斜杠**(Windows 的反斜杠在 markdown 里是转义符)。
9
+
10
+ import (
11
+ "os"
12
+ "path/filepath"
13
+ "strconv"
14
+ "strings"
15
+ "testing"
16
+ )
17
+
18
+ // fakePNG 造一个最小 PNG(只需要文件头合法,core 不解码像素)。
19
+ func fakePNG() []byte {
20
+ return append(append([]byte{}, pngMagic...), 0x00, 0x01, 0x02, 0x03)
21
+ }
22
+
23
+ // 造一篇已存在的功能点文档,返回工作区 id 与文档路径。
24
+ func seedFeature(t *testing.T, module, name string) (string, string) {
25
+ t.Helper()
26
+ root := t.TempDir()
27
+ t.Setenv(EnvDir, filepath.Join(root, "requirements"))
28
+ id := "ws-abc1234567"
29
+ dir := filepath.Join(featuresDir(id), module)
30
+ if err := os.MkdirAll(dir, 0o700); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ file := filepath.Join(dir, SlugifyFeature(name)+".md")
34
+ doc := RenderDoc(name, Doc{
35
+ Desc: "一个功能点",
36
+ Points: []string{"要点一"},
37
+ Conclusions: []string{"结论一"},
38
+ Final: "**目标**:一个功能点",
39
+ })
40
+ if err := os.WriteFile(file, []byte(doc), 0o600); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ return id, file
44
+ }
45
+
46
+ func TestAttachShotAppendsToDoc(t *testing.T) {
47
+ id, file := seedFeature(t, "任务列表", "需求页")
48
+ res, err := AttachShot(id, "任务列表", "需求页", "改成双列布局", fakePNG())
49
+ if err != nil {
50
+ t.Fatalf("追加截图失败: %v", err)
51
+ }
52
+ if !res.OK || res.Shots != 1 {
53
+ t.Fatalf("结果不对: %+v", res)
54
+ }
55
+ if !strings.Contains(res.Path, "/") || strings.Contains(res.Path, `\`) {
56
+ t.Errorf("截图路径应是正斜杠: %q", res.Path)
57
+ }
58
+ if _, err := os.Stat(filepath.FromSlash(res.Path)); err != nil {
59
+ t.Errorf("截图文件不在盘上: %v", err)
60
+ }
61
+ raw, err := os.ReadFile(file)
62
+ if err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ text := string(raw)
66
+ if !strings.Contains(text, "## 页面完成情况") {
67
+ t.Fatalf("文档里没有新增段落:\n%s", text)
68
+ }
69
+ if !strings.Contains(text, "改成双列布局") {
70
+ t.Errorf("文档里没有标注说明:\n%s", text)
71
+ }
72
+ // 原有内容必须还在。
73
+ if !strings.Contains(text, "## 需求点") || !strings.Contains(text, "要点一") {
74
+ t.Errorf("原有内容被弄丢了:\n%s", text)
75
+ }
76
+ // 段的位置:在来源对话之前。
77
+ if strings.Index(text, "## 页面完成情况") > strings.Index(text, "## 来源对话") {
78
+ t.Errorf("新段应在来源对话之前:\n%s", text)
79
+ }
80
+ }
81
+
82
+ func TestAttachShotKeepsUserEdits(t *testing.T) {
83
+ // 用户在两次归纳之间手改了文档(加了一个自定义段 + 改了措辞)。
84
+ // 追加截图**不能**把那些内容抹掉 —— 这就是「文本级插入」的意义。
85
+ id, file := seedFeature(t, "任务列表", "需求页")
86
+ raw, _ := os.ReadFile(file)
87
+ edited := string(raw) + "\n## 我自己加的备注\n这段是用户手写的\n"
88
+ if err := os.WriteFile(file, []byte(edited), 0o600); err != nil {
89
+ t.Fatal(err)
90
+ }
91
+ if _, err := AttachShot(id, "任务列表", "需求页", "标注", fakePNG()); err != nil {
92
+ t.Fatalf("追加截图失败: %v", err)
93
+ }
94
+ after, _ := os.ReadFile(file)
95
+ if !strings.Contains(string(after), "## 我自己加的备注") {
96
+ t.Errorf("用户手写的内容被抹掉了:\n%s", after)
97
+ }
98
+ if !strings.Contains(string(after), "这段是用户手写的") {
99
+ t.Errorf("用户手写的内容被抹掉了:\n%s", after)
100
+ }
101
+ }
102
+
103
+ func TestAttachShotTwiceKeepsBoth(t *testing.T) {
104
+ id, file := seedFeature(t, "任务列表", "需求页")
105
+ if _, err := AttachShot(id, "任务列表", "需求页", "第一张", fakePNG()); err != nil {
106
+ t.Fatal(err)
107
+ }
108
+ res, err := AttachShot(id, "任务列表", "需求页", "第二张", fakePNG())
109
+ if err != nil {
110
+ t.Fatalf("第二次追加失败: %v", err)
111
+ }
112
+ if res.Shots != 2 {
113
+ t.Errorf("应有 2 张截图,实际 %d", res.Shots)
114
+ }
115
+ raw, _ := os.ReadFile(file)
116
+ text := string(raw)
117
+ if !strings.Contains(text, "第一张") || !strings.Contains(text, "第二张") {
118
+ t.Errorf("两张截图都该在文档里:\n%s", text)
119
+ }
120
+ // 占位符不该留着。
121
+ if strings.Contains(text, "(暂无)\n## 来源对话") {
122
+ t.Errorf("有图后不该还留着占位符:\n%s", text)
123
+ }
124
+ }
125
+
126
+ func TestAttachShotErrors(t *testing.T) {
127
+ id, _ := seedFeature(t, "任务列表", "需求页")
128
+ // 找不到功能点。
129
+ if _, err := AttachShot(id, "任务列表", "不存在的功能", "x", fakePNG()); err == nil {
130
+ t.Error("找不到功能点应当报错")
131
+ }
132
+ // 模块对不上。
133
+ if _, err := AttachShot(id, "别的模块", "需求页", "x", fakePNG()); err == nil {
134
+ t.Error("模块对不上应当报错")
135
+ }
136
+ // 不是 PNG。
137
+ if _, err := AttachShot(id, "任务列表", "需求页", "x", []byte("not a png")); err == nil {
138
+ t.Error("非 PNG 应当报错")
139
+ }
140
+ // 空内容。
141
+ if _, err := AttachShot(id, "任务列表", "需求页", "x", nil); err == nil {
142
+ t.Error("空内容应当报错")
143
+ }
144
+ // 超限。
145
+ if _, err := AttachShot(id, "任务列表", "需求页", "x", make([]byte, MaxShotBytes+1)); err == nil {
146
+ t.Error("超限应当报错")
147
+ }
148
+ // 功能点名空。
149
+ if _, err := AttachShot(id, "任务列表", " ", "x", fakePNG()); err == nil {
150
+ t.Error("功能点名空应当报错")
151
+ }
152
+ }
153
+
154
+ func TestDocRoundTripKeepsShots(t *testing.T) {
155
+ // 归纳重写文档时(ParseDoc → RenderDoc)要保住截图与页面地址。
156
+ d := Doc{
157
+ Desc: "描述",
158
+ Points: []string{"要点"},
159
+ Conclusions: []string{"结论"},
160
+ PageURL: "https://example.com/page",
161
+ Shots: []Shot{
162
+ {Path: "C:/Users/x/a b (v2)/1.png", Caption: "改完了", At: 1793000000000},
163
+ },
164
+ }
165
+ out := RenderDoc("需求页", d)
166
+ back := ParseDoc(out)
167
+ if back.PageURL != d.PageURL {
168
+ t.Errorf("页面地址丢了: %q", back.PageURL)
169
+ }
170
+ if len(back.Shots) != 1 {
171
+ t.Fatalf("截图丢了: %+v", back.Shots)
172
+ }
173
+ if back.Shots[0].Path != d.Shots[0].Path {
174
+ t.Errorf("截图路径不对: %q", back.Shots[0].Path)
175
+ }
176
+ if back.Shots[0].Caption != "改完了" {
177
+ t.Errorf("标注说明不对: %q", back.Shots[0].Caption)
178
+ }
179
+ if back.Shots[0].At != d.Shots[0].At {
180
+ t.Errorf("截图时间不对: %d", back.Shots[0].At)
181
+ }
182
+ }
183
+
184
+ func TestRenderDocWritesShotsSection(t *testing.T) {
185
+ out := RenderDoc("需求页", Doc{Points: []string{"要点"}})
186
+ if !strings.Contains(out, "## 页面完成情况") {
187
+ t.Errorf("空文档也该有这一段(占位):\n%s", out)
188
+ }
189
+ if !strings.Contains(out, "(暂无)") {
190
+ t.Errorf("空段该写占位:\n%s", out)
191
+ }
192
+ }
193
+
194
+ func TestValidPageURL(t *testing.T) {
195
+ good := []string{"https://example.com", "http://a.b/c?d=1", "HTTPS://X.COM"}
196
+ bad := []string{"", "无", "待定", "见上文", "C:/Users/x", "ftp://x", "javascript:alert(1)"}
197
+ for _, u := range good {
198
+ if !validPageURL(u) {
199
+ t.Errorf("应认: %q", u)
200
+ }
201
+ }
202
+ for _, u := range bad {
203
+ if validPageURL(u) {
204
+ t.Errorf("不该认: %q", u)
205
+ }
206
+ }
207
+ }
208
+
209
+ func TestConsolidateKeepsShots(t *testing.T) {
210
+ // 假模型:返回一个已有功能点的增量(只有 desc)。
211
+ // 断言:重写文档后截图段仍在、页面地址仍在。
212
+ root := t.TempDir()
213
+ t.Setenv(EnvDir, filepath.Join(root, "requirements"))
214
+ id := "ws-abc1234567"
215
+ dir := filepath.Join(featuresDir(id), "任务列表")
216
+ if err := os.MkdirAll(dir, 0o700); err != nil {
217
+ t.Fatal(err)
218
+ }
219
+ file := filepath.Join(dir, "需求页.md")
220
+ doc := RenderDoc("需求页", Doc{
221
+ Desc: "旧描述",
222
+ Points: []string{"要点"},
223
+ PageURL: "https://example.com/p",
224
+ Shots: []Shot{{Path: "C:/x/1.png", Caption: "已改", At: 1793000000000}},
225
+ Conclusions: []string{},
226
+ })
227
+ if err := os.WriteFile(file, []byte(doc), 0o600); err != nil {
228
+ t.Fatal(err)
229
+ }
230
+ merger := newDocMerger(id)
231
+ w := writeFeatureItem(id, featureOut{
232
+ Module: "任务列表",
233
+ Name: "需求页",
234
+ Desc: "新描述",
235
+ }, &writeCtx{merger: merger, kind: "归纳"})
236
+ if !w.ok {
237
+ t.Fatal("写入失败")
238
+ }
239
+ raw, _ := os.ReadFile(file)
240
+ back := ParseDoc(string(raw))
241
+ if len(back.Shots) != 1 {
242
+ t.Fatalf("归纳把截图抹掉了:\n%s", raw)
243
+ }
244
+ if back.Shots[0].Caption != "已改" {
245
+ t.Errorf("标注说明丢了: %+v", back.Shots)
246
+ }
247
+ if back.PageURL != "https://example.com/p" {
248
+ t.Errorf("页面地址丢了: %q", back.PageURL)
249
+ }
250
+ if back.Desc != "新描述" {
251
+ t.Errorf("描述没更新: %q", back.Desc)
252
+ }
253
+ }
254
+
255
+ func TestConsolidateKeepsPageURLWhenModelEmpty(t *testing.T) {
256
+ // 本轮模型没给 page_url(空串)→ 保留旧的,不能被空值抹掉。
257
+ root := t.TempDir()
258
+ t.Setenv(EnvDir, filepath.Join(root, "requirements"))
259
+ id := "ws-abc1234567"
260
+ dir := filepath.Join(featuresDir(id), "任务列表")
261
+ _ = os.MkdirAll(dir, 0o700)
262
+ file := filepath.Join(dir, "需求页.md")
263
+ doc := RenderDoc("需求页", Doc{Desc: "旧", PageURL: "https://example.com/keep"})
264
+ _ = os.WriteFile(file, []byte(doc), 0o600)
265
+ merger := newDocMerger(id)
266
+ w := writeFeatureItem(id, featureOut{Module: "任务列表", Name: "需求页", Desc: "新"}, &writeCtx{merger: merger, kind: "归纳"})
267
+ if !w.ok {
268
+ t.Fatal("写入失败")
269
+ }
270
+ raw, _ := os.ReadFile(file)
271
+ if back := ParseDoc(string(raw)); back.PageURL != "https://example.com/keep" {
272
+ t.Errorf("空 page_url 不该抹掉旧地址: %q", back.PageURL)
273
+ }
274
+ }
275
+
276
+ func TestConsolidateRejectsBadPageURL(t *testing.T) {
277
+ // 模型给了个非法地址(不是 http/https)→ 保留旧的,不写进去。
278
+ root := t.TempDir()
279
+ t.Setenv(EnvDir, filepath.Join(root, "requirements"))
280
+ id := "ws-abc1234567"
281
+ dir := filepath.Join(featuresDir(id), "任务列表")
282
+ _ = os.MkdirAll(dir, 0o700)
283
+ file := filepath.Join(dir, "需求页.md")
284
+ doc := RenderDoc("需求页", Doc{Desc: "旧", PageURL: "https://example.com/keep"})
285
+ _ = os.WriteFile(file, []byte(doc), 0o600)
286
+ merger := newDocMerger(id)
287
+ w := writeFeatureItem(id, featureOut{Module: "任务列表", Name: "需求页", Desc: "新", PageURL: "javascript:alert(1)"},
288
+ &writeCtx{merger: merger, kind: "归纳"})
289
+ if !w.ok {
290
+ t.Fatal("写入失败")
291
+ }
292
+ raw, _ := os.ReadFile(file)
293
+ if back := ParseDoc(string(raw)); back.PageURL != "https://example.com/keep" {
294
+ t.Errorf("非法地址不该写进去: %q", back.PageURL)
295
+ }
296
+ }
297
+
298
+ func TestAttachShotDedupesSamePath(t *testing.T) {
299
+ // 同一张图被写进文档两遍 → 段里只该有一行(留最后那条)。
300
+ // 触发方式:把同一行手工塞进文档,再追加一张**同名**的图。
301
+ id, file := seedFeature(t, "任务列表", "需求页")
302
+ if _, err := AttachShot(id, "任务列表", "需求页", "第一张", fakePNG()); err != nil {
303
+ t.Fatalf("追加截图失败: %v", err)
304
+ }
305
+ raw, _ := os.ReadFile(file)
306
+ // 把已有那行原样复制一遍(模拟重放/手工编辑造成的重复)。
307
+ lines := strings.Split(string(raw), "\n")
308
+ dup := ""
309
+ for _, l := range lines {
310
+ if strings.Contains(l, "第一张") {
311
+ dup = l
312
+ break
313
+ }
314
+ }
315
+ if dup == "" {
316
+ t.Fatalf("文档里没找到截图行:\n%s", raw)
317
+ }
318
+ edited := strings.Replace(string(raw), dup, dup+"\n"+dup, 1)
319
+ if err := os.WriteFile(file, []byte(edited), 0o600); err != nil {
320
+ t.Fatal(err)
321
+ }
322
+ // 再追加一张新图 —— insertShot 应顺手把重复行去掉。
323
+ res, err := AttachShot(id, "任务列表", "需求页", "第二张", fakePNG())
324
+ if err != nil {
325
+ t.Fatalf("追加截图失败: %v", err)
326
+ }
327
+ if res.Shots != 2 {
328
+ t.Errorf("去重后应有 2 张(两张不同路径),实际 %d", res.Shots)
329
+ }
330
+ after, _ := os.ReadFile(file)
331
+ if n := strings.Count(string(after), "第一张"); n != 1 {
332
+ t.Errorf("重复的截图行没去掉,出现 %d 次:\n%s", n, after)
333
+ }
334
+ }
335
+
336
+ func TestTrimShotsDedupesAndTrims(t *testing.T) {
337
+ shots := []Shot{
338
+ {Path: "C:/x/1.png", Caption: "a"},
339
+ {Path: "C:/x/2.png", Caption: "b"},
340
+ {Path: "C:/x/1.png", Caption: "a2"},
341
+ }
342
+ out := TrimShots(shots)
343
+ if len(out) != 2 {
344
+ t.Fatalf("去重后应有 2 条,实际 %d: %+v", len(out), out)
345
+ }
346
+ // 留最后一条的**内容**,但位置不动(首次出现的位置)。
347
+ if out[0].Path != "C:/x/1.png" || out[0].Caption != "a2" {
348
+ t.Errorf("该留最后一条的内容(位置不动),实际 %+v", out[0])
349
+ }
350
+ if out[1].Path != "C:/x/2.png" {
351
+ t.Errorf("第二条不对: %+v", out[1])
352
+ }
353
+ // 超上限时丢最旧的。
354
+ many := make([]Shot, 0, MaxShots+5)
355
+ for i := 0; i < MaxShots+5; i++ {
356
+ many = append(many, Shot{Path: "C:/x/" + strconv.Itoa(i) + ".png"})
357
+ }
358
+ got := TrimShots(many)
359
+ if len(got) != MaxShots {
360
+ t.Fatalf("该裁到 %d 条,实际 %d", MaxShots, len(got))
361
+ }
362
+ if got[0].Path != "C:/x/5.png" {
363
+ t.Errorf("该丢最旧的 5 条,实际第一条 %q", got[0].Path)
364
+ }
365
+ }
366
+
367
+ func TestRenderDocShotLinesAreCounted(t *testing.T) {
368
+ // RenderDoc 写的是列表项 `- ![…](…)`;这行必须能被 countShots 数出来,
369
+ // 也必须能被 ParseDoc 解析回来 —— 否则归纳重渲染后截图就「消失」了。
370
+ out := RenderDoc("需求页", Doc{
371
+ PageURL: "https://example.com/p",
372
+ Shots: []Shot{{Path: "C:/x/1.png", Caption: "改完了", At: 1793000000000}},
373
+ })
374
+ if !strings.Contains(out, "- ![改完了](C:/x/1.png)") {
375
+ t.Errorf("RenderDoc 的截图形状不对:\n%s", out)
376
+ }
377
+ if n := countShots(strings.Split(out, "\n")); n != 1 {
378
+ t.Errorf("countShots 数不出 RenderDoc 写的截图(得 %d):\n%s", n, out)
379
+ }
380
+ back := ParseDoc(out)
381
+ if len(back.Shots) != 1 {
382
+ t.Fatalf("ParseDoc 解析不回来: %+v", back.Shots)
383
+ }
384
+ if back.Shots[0].Caption != "改完了" || back.Shots[0].Path != "C:/x/1.png" {
385
+ t.Errorf("解析结果不对: %+v", back.Shots[0])
386
+ }
387
+ }