package com.yunhai.agent; import com.yunhai.agent.config.AgentSettings; import com.yunhai.agent.model.BrowserToolRequest; import com.yunhai.agent.service.AgentOrchestrator; import com.yunhai.agent.tool.FormField; import com.yunhai.agent.tool.ToolDef; import com.yunhai.agent.tool.ToolExecutor; import com.yunhai.agent.tool.ToolParam; import com.yunhai.agent.tool.ToolRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.web.client.RestTemplate; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** * 「五个模块的发布页字段与助手表单完全对齐」的契约断言。 * *

逐条钉住本轮从模块源码里抠出来的差异 —— 这些都是**会真的提交出问题**的地方, * 不是风格问题: *

    *
  1. 公告 {@code publishType} 合法值是 {@code direct / approve}(存草稿走 {@code SaveDraft} 接口);
  2. *
  3. 活动 {@code category} 存的是分类**名称**(模块页写的是 {@code :value="String(c.v)"});
  4. *
  5. 请假 {@code fileIds} 是 {@code number[]},**不能**拼成逗号串;
  6. *
  7. 公告/宿舍/活动三处的 {@code deptName} 要按所选部门镜像过去;
  8. *
  9. 作业「发布」是两步(先落草稿 → 再发布/定时),附件是对象数组;
  10. *
  11. 公告审批流 {@code flow} 与教师树 {@code notice.teaTree}。
  12. *
*/ class ModuleFormParityTest { private ToolRegistry registry; private AgentSettings settings; private ToolExecutor executor; @BeforeEach void setUp() { settings = new AgentSettings(); settings.getModules().put("campusnotice", "http://base.yunhaixingtong.com/campusnotice"); // 通知公告:**另一个应用**(NoticeFront),部署前缀 /notice —— 别与 campusnotice 混 settings.getModules().put("notice", "http://base.yunhaixingtong.com/notice"); settings.getModules().put("dormnotice", "http://base.yunhaixingtong.com/dormnotice"); settings.getModules().put("familyactivity", "http://base.yunhaixingtong.com/familyactivity"); // ★ 前缀是 /home-leave(差一个连字符)—— 早先这里跟着代码一起写成 /homeleave, // 于是「附件上传地址拼错」这个 bug 在测试里也被一并复制、永远测不出来。 settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/home-leave"); settings.getModules().put("coursehomework", "http://base.yunhaixingtong.com/coursehomework"); // 专业发展:模块挂在自己的路径前缀下(/profession),代取地址与附件上传都要用它 settings.getModules().put("profession", "http://base.yunhaixingtong.com/profession"); registry = new ToolRegistry(settings); registry.init(); executor = new ToolExecutor(settings, new RestTemplate()); } /* ==================== ① 公告:发布方式值域 / 存草稿独立接口 ==================== */ @Test void noticePublishTypeOnlyAllowsDirectOrApprove() { ToolParam publishType = param(registry.get("notice.publish"), "publishType"); assertNotNull(publishType, "公告发布要有「发布方式」"); assertEquals(Arrays.asList("direct", "approve"), publishType.getEnumValues(), "模块页 publishType 只有 direct / approve —— 草稿走的是 SaveDraft 接口,不是 Create 的一个取值"); } @Test void noticeSaveDraftIsItsOwnEndpointWithoutPublishType() { ToolDef draft = registry.get("notice.saveDraft"); assertNotNull(draft, "公告要有独立的「存为草稿」工具"); assertEquals("/api/notice/SaveDraft", draft.getPath(), "存草稿走模块自己的 SaveDraft 接口"); assertFalse(draft.isReadOnly(), "存草稿是写操作"); assertTrue(draft.isFormAll(), "存草稿表单同样渲染全部字段"); assertNull(param(draft, "publishType"), "SaveDraft 不传 publishType(模块页这一步也不传)"); // 其余字段与发布工具保持一致 for (String name : Arrays.asList("title", "content", "deptId", "deptName", "pushScopes", "topmost", "smsFlag", "receiptFlag", "fileIds", "timingSendTime", "flow")) { assertNotNull(param(draft, name), "存草稿表单缺字段:" + name); } } @Test void noticePublishCarriesApprovalFlowAndTeacherTree() { ToolDef def = registry.get("notice.publish"); ToolParam flow = param(def, "flow"); assertNotNull(flow, "公告要有审批流字段(模块页的 ApprovalFlowEditor)"); assertEquals("flow", flow.resolveWidget(), "审批流要用专用控件"); assertEquals("notice.teaTree", flow.getOptionsFrom(), "审批人候选来自教师树"); assertEquals("id", flow.getOptionValueKey()); assertEquals("name", flow.getOptionLabelKey()); ToolDef teaTree = registry.get("notice.teaTree"); assertNotNull(teaTree, "要有教师树字典工具"); assertEquals("/api/base/getTeaTree", teaTree.getPath()); assertEquals(Boolean.TRUE, teaTree.getFixed().get("toUser"), "审批人取的是「用户态」教师叶子"); } /* ==================== ② 三个模块:部门名称镜像 ==================== */ @Test void deptNameIsMirroredFromDeptIdEverywhere() { for (String tool : Arrays.asList("notice.publish", "dorm.publish", "activity.publish")) { ToolParam deptName = param(registry.get(tool), "deptName"); assertNotNull(deptName, tool + " 要带上部门名称字段"); assertEquals("deptId", deptName.getMirrorLabelOf(), tool + " 的 deptName 应由所选部门镜像得到(模块页就是这么做的)"); // 镜像字段不能让模型去编 —— 它必须从模型的工具 schema 里排除 String schema = registry.get(tool).toOpenAiTool().toString(); assertFalse(schema.contains("\"deptName\""), tool + " 的 deptName 不该出现在给模型的 schema 里"); } } /* ==================== ③ 活动:分类存名称 ==================== */ @Test void activityCategoryStoresNameNotId() { ToolParam category = param(registry.get("activity.publish"), "category"); assertNotNull(category); assertEquals("v", category.getOptionValueKey(), "模块页 NewActivity.vue 写的是 :value=\"String(c.v)\" —— 存的是分类名称,不是字典 k"); } /* ==================== ④ 请假:fileIds 是数组 + 连续请假展开 ==================== */ @Test void leaveFileIdsStaysArrayAndSupportsMultiple() { for (String tool : Arrays.asList("leave.submit", "leave.register")) { ToolParam fileIds = param(registry.get(tool), "fileIds"); assertNotNull(fileIds, tool + " 要有佐证材料"); assertEquals("file", fileIds.resolveWidget(), tool + " 的佐证材料应是上传控件"); assertNull(fileIds.getJoinSeparator(), tool + " 的 fileIds 在模块里是 number[](JSON 数组),不能拼成逗号串"); assertTrue(fileIds.getMultiple(), tool + " 的佐证材料可多份"); } } @Test void leaveContinuousLeaveDeclaresDailyExpand() { for (String tool : Arrays.asList("leave.submit", "leave.register")) { ToolParam flag = param(registry.get(tool), "continuousLeave"); assertNotNull(flag, tool + " 要有连续请假开关"); Map spec = flag.getDailyExpandSpec(); assertNotNull(spec, tool + " 的连续请假要声明逐天展开,否则生成不出 continuousLeaveTime"); assertEquals("continuousLeaveNum", spec.get("count")); assertEquals("startDate", spec.get("start")); assertEquals("endDate", spec.get("end")); } } @Test void continuousLeaveExpandsIntoPerDayRanges() { ToolDef def = registry.get("leave.submit"); Map req = new LinkedHashMap<>(); req.put("leaveType", 1); req.put("remarks", "发烧就医"); req.put("startDate", "2026-10-13 08:00:00"); req.put("endDate", "2026-10-13 12:00:00"); req.put("continuousLeave", 1); req.put("continuousLeaveNum", 3); BrowserToolRequest.BrowserCall call = executor.prepareBrowserCall(def, req); String body = call.getBody(); assertTrue(body.contains("continuousLeaveTime"), "要派生出 continuousLeaveTime:" + body); assertTrue(body.contains("2026-10-13 08:00:00"), body); assertTrue(body.contains("2026-10-14 08:00:00"), "第 2 天要顺延一天:" + body); assertTrue(body.contains("2026-10-15 12:00:00"), "第 3 天要顺延两天:" + body); // 非连续请假时不产生该字段 req.put("continuousLeave", 0); assertFalse(executor.prepareBrowserCall(def, req).getBody().contains("continuousLeaveTime"), "没勾连续请假就不该派生出连续时间段"); } /* ==================== ⑤ 作业:教师端发布(两步 + 对象数组附件) ==================== */ @Test void homeworkPublishToolContract() { ToolDef def = registry.get("homework.publish"); assertNotNull(def, "作业模块要有教师端「发布作业」工具"); assertFalse(def.isReadOnly(), "发布作业是写操作"); assertTrue(def.isFormAll(), "发布表单渲染全部字段,与「新建作业」页一致"); assertEquals("/api/teacher/homeworks", def.getPath()); assertEquals("/api/teacher/homeworks/{data}/publish", def.getFollowUpPath(), "模块没有一步发布的接口:要先 Create 落草稿,再用返回的 id 发布"); for (String name : Arrays.asList("title", "subject", "classIds", "deadlineAt", "submitType", "allowLate", "remind1d", "remind1h", "attachments", "description")) { assertNotNull(param(def, name), "发布作业缺字段:" + name); } assertTrue(param(def, "title").isRequired()); assertTrue(param(def, "classIds").isRequired()); assertEquals("array", param(def, "classIds").getType(), "接收班级是数组(模块是 classIds: string[])"); assertEquals("homework.classTree", param(def, "classIds").getOptionsFrom()); } @Test void homeworkScheduleToolDeclaresSecondStep() { ToolDef def = registry.get("homework.schedule"); assertNotNull(def, "要有定时发布作业"); assertEquals("/api/teacher/homeworks", def.getPath()); assertEquals("/api/teacher/homeworks/{data}/schedule", def.getFollowUpPath()); assertTrue(def.getFollowUpBody().contains("scheduledAt"), "第二步要把定时时间带过去"); assertTrue(param(def, "scheduledAt").isRequired(), "定时发布必须给出定时时间"); } @Test void homeworkAttachmentsAreObjectsUploadedToFileContext() { ToolParam att = param(registry.get("homework.publish"), "attachments"); assertNotNull(att); assertEquals("file", att.resolveWidget()); assertTrue(att.isFileObject(), "作业的 attachments 是 [{fileId,fileUrl,fileType,fileName,sort}] 对象数组"); assertEquals("/api/file/Upload", att.getUploadPathOverride(), "声明只写模块内相对路径(前缀由编排层拼)"); assertEquals("/coursehomework/api/file/Upload", emittedUploadPath(registry.get("homework.publish"), att), "作业模块的附件上传走 file 域,与其余模块的 base/FileUpload 不同"); } @Test void homeworkPublishFollowUpKeepsTheDraftId() { ToolDef def = registry.get("homework.publish"); Map req = new LinkedHashMap<>(); req.put("title", "第三章课后练习"); req.put("subject", "数学"); req.put("classIds", Arrays.asList("3", "4")); BrowserToolRequest.BrowserCall call = executor.prepareBrowserCall(def, req); assertNotNull(call.getFollowUp(), "发布要挂上第二步"); assertEquals("/coursehomework/api/teacher/homeworks/{data}/publish", call.getFollowUp().getPathAndQuery(), "第二步地址要带模块前缀,并留 {data} 给首步的 id"); } /* ==================== ⑥ 附件上传地址:前缀必须取自配置,不能用模块 key 拼 ==================== */ /** * ★ 回归:附件上传地址的前缀必须取自 `agent.modules` 的配置。 * *

请假的 key 是 {@code homeleave},真实路由前缀是 {@code /home-leave} —— 差一个连字符。 * 早先按 {@code "/" + def.getModule() + "/api/base/FileUpload"} 拼,请求打到 * {@code /homeleave/api/base/FileUpload},被网关当成不存在的应用直接 **404** * —— 用户上传佐证材料时实际撞到的就是这个。 */ @Test void uploadPathPrefixComesFromConfiguredUrlNotModuleKey() { assertEquals("/home-leave", AgentOrchestrator.pathPrefix("homeleave", "http://base.yunhaixingtong.com/home-leave"), "请假的前缀带连字符,不能拿 key(homeleave) 顶替"); assertEquals("/home-leave/api/base/FileUpload", AgentOrchestrator.pathPrefix("homeleave", "http://base.yunhaixingtong.com/home-leave") + "/api/base/FileUpload"); assertEquals("/campusnotice", AgentOrchestrator.pathPrefix("campusnotice", "http://base.yunhaixingtong.com/campusnotice")); assertEquals("/dormnotice", AgentOrchestrator.pathPrefix("dormnotice", "http://base.yunhaixingtong.com/dormnotice/"), "尾部斜杠要吃掉,否则会拼出 //api/base/FileUpload"); // 地址没有路径部分 / 完全未配置 → 退回 key 前缀,至少不会拼出空串 assertEquals("/x", AgentOrchestrator.pathPrefix("x", "http://host")); assertEquals("/y", AgentOrchestrator.pathPrefix("y", null)); } /** * 六个模块的附件上传地址:逐条钉住**线上真实地址**(对照各模块发布页源码核出来的)。 * *

这里是「用生产代码算、拿真实地址比」,而不是把拼接逻辑在测试里再抄一遍 —— * 上一版测试就是自己拼 {@code pathPrefix(...) + "/api/base/FileUpload"}, * 于是 {@code profession.publish} 声明成 {@code /api/file/Upload} 却**从不经过前缀拼接** * 这件事,测试根本看不见,线上直接 404。 * *

各模块真实地址的出处: *

    *
  • 公告 {@code NoticeUpload.vue}:{@code serviceHost + "/api/base/FileUpload"}
  • *
  • 宿舍/活动/请假 {@code AttachmentUpload.vue}:同上
  • *
  • 作业 {@code AttachmentUpload.vue}:{@code serviceHost + "/api/file/Upload"}(走 file 域)
  • *
  • 专业发展 {@code RecordEdit.vue} 的 {@code uploadAction = fileUploadUrl} = {@code serviceHost + "/api/file/Upload"}
  • *
* 其中 {@code serviceHost} 由各模块 {@code host.config.ts} 推出 = {@code origin + 当前路径首段}, * 正是 {@code agent.modules} 里配置的那个路径前缀。 */ @Test void everyPublishToolUploadPathMatchesItsModuleRealEndpoint() { Map cases = new LinkedHashMap<>(); cases.put("notice.publish", new String[]{"/campusnotice/api/base/FileUpload", "NoticeUpload.vue"}); cases.put("notify.publish", new String[]{"/notice/api/base/FileUpload", "NoticeFront 的 components/NoticeUpload.vue(与校园公告是两个工程)"}); cases.put("dorm.publish", new String[]{"/dormnotice/api/base/FileUpload", "AttachmentUpload.vue"}); cases.put("activity.publish", new String[]{"/familyactivity/api/base/FileUpload", "AttachmentUpload.vue"}); cases.put("leave.register", new String[]{"/home-leave/api/base/FileUpload", "AttachmentUpload.vue"}); cases.put("homework.publish", new String[]{"/coursehomework/api/file/Upload", "走 file 域,不是 base/FileUpload"}); cases.put("profession.publish", new String[]{"/profession/api/file/Upload", "RecordEdit.vue 的 fileUploadUrl"}); for (Map.Entry e : cases.entrySet()) { ToolDef def = registry.get(e.getKey()); assertNotNull(def, "工具不存在:" + e.getKey()); ToolParam fileParam = firstFileParam(def); assertNotNull(fileParam, e.getKey() + " 应当有附件字段"); assertEquals(e.getValue()[0], emittedUploadPath(def, fileParam), e.getKey() + " 的附件上传地址(" + e.getValue()[1] + ")"); } } /** * 通用不变量:**任何一个**附件字段的最终上传地址都必须以它所属模块的路由前缀开头。 * *

不写死模块清单,以后新增模块/新增附件字段会自动被覆盖 —— * 这正是「拼错前缀」和「压根没拼前缀」两类 404 的公共判据。 */ @Test void noUploadPathIsEverEmittedWithoutItsModulePrefix() { int checked = 0; for (ToolDef def : registry.all()) { ToolParam p = firstFileParam(def); if (p == null) { continue; } String prefix = AgentOrchestrator.pathPrefix(def.getModule(), settings.getModules().get(def.getModule())); String path = emittedUploadPath(def, p); assertTrue(path.startsWith("/"), def.getName() + " 的上传地址要以 / 开头:" + path); assertTrue(path.startsWith(prefix + "/"), def.getName() + " 的上传地址必须带模块前缀 " + prefix + ",实际:" + path); assertFalse(path.contains("//"), def.getName() + " 的上传地址不能出现双斜杠:" + path); // ★ .upload() 里只许写模块内相对路径:带上前缀会让「有没有拼」这件事重新变得依赖调用方 if (p.getUploadPathOverride() != null && !p.getUploadPathOverride().isEmpty()) { assertFalse(p.getUploadPathOverride().startsWith(prefix), def.getName() + " 的 .upload() 只写模块内相对路径(如 /api/file/Upload)," + "前缀由编排层拼,实际写了:" + p.getUploadPathOverride()); } checked++; } assertTrue(checked >= 6, "六个模块的附件字段都应被覆盖到,实际只检查了 " + checked + " 个"); } /** 按生产代码的口径算出「最终下发给前端的上传地址」 */ private String emittedUploadPath(ToolDef def, ToolParam fileParam) { return AgentOrchestrator.resolveUploadPath(def.getModule(), settings.getModules().get(def.getModule()), fileParam.getUploadPathOverride()); } /** 工具定义里第一个附件字段(widget=file) */ private ToolParam firstFileParam(ToolDef def) { if (def == null || def.getParams() == null) { return null; } for (ToolParam p : def.getParams()) { if ("file".equals(p.getWidget())) { return p; } } return null; } /* ==================== ⑦ 附件控件渲染 ==================== */ @Test void scalarValueIsWrappedForArrayParams() { ToolDef def = registry.get("homework.publish"); Map req = new LinkedHashMap<>(); req.put("title", "标题"); req.put("subject", "语文"); req.put("classIds", "3"); req.put("submitType", "image"); String body = executor.prepareBrowserCall(def, req).getBody(); assertTrue(body.contains("\"classIds\":[\"3\"]"), "标量要包成数组:" + body); assertTrue(body.contains("\"submitType\":[\"image\"]"), "标量要包成数组:" + body); } /* ==================== ⑥ 教师专业发展:发布记录(RecordEdit.vue) ==================== */ /** * 发布字段与模块页 {@code ProfessionView/src/views/profession/RecordEdit.vue} 逐项一致。 * *

★ 这条钉的是一个**真实提交出错**的差异:早先助手只发 {@code content}, * 而模块页发的是 {@code remark}(正文)—— 字段名不对,正文根本没存进去, * 同时「年级段 / 学科 / 是否公开 / 附件」四项完全缺失,发出去的记录与在系统里发的天然不同。 */ @Test void professionPublishFieldsMatchRecordEditForm() { ToolDef def = registry.get("profession.publish"); assertNotNull(def, "要有「发布专业发展记录」工具"); assertEquals(Arrays.asList( "moduleId", "title", "grade", "subject", "ifOpen", "remark", "contents", "fileIds"), paramNames(def), "字段与顺序都要对齐模块页:标题 → 年级段/学科 → 是否公开 →(栏目动态字段)→ 正文 → 附件"); assertNull(param(def, "content"), "正文的字段名是 remark —— 模块页不叫 content"); } @Test void professionPublishIsPostFormFullFormAndStaffOnly() { ToolDef def = registry.get("profession.publish"); assertEquals("POST_FORM", def.getHttpMethod(), "模块页 create() 走 postFormUrl(form-urlencoded)"); assertEquals("/api/profession/Create", def.getPath()); assertTrue(def.isFormAll(), "发布页要渲染全部字段(与模块页「发布」页一致)"); assertFalse(def.isReadOnly(), "发布是写操作"); assertEquals("/record/new", def.getLandingPath()); assertTrue(def.allowsAny(new java.util.HashSet<>(Arrays.asList(com.yunhai.agent.auth.AgentRole.TEACHER))), "专业发展是教职工工具"); assertFalse(def.allowsAny(new java.util.HashSet<>(Arrays.asList(com.yunhai.agent.auth.AgentRole.STUDENT))), "学生不应看到专业发展发布"); } @Test void professionPublishFieldControlsMatchModulePage() { ToolDef def = registry.get("profession.publish"); ToolParam title = param(def, "title"); assertTrue(title.isRequired(), "标题必填"); assertEquals(200, title.getMaxLength(), "模块页 :maxlength=\"200\""); assertEquals("请输入记录标题", title.getPlaceholderText(), "占位文案照搬模块页"); // 年级段 / 学科:两个可空下拉,选项都来自模块字典(服务端取不到 → 前端带登录态代取) assertEquals("profession.grades", param(def, "grade").getOptionsFrom()); assertEquals(Arrays.asList("value", "label"), optionsKeysOf(param(def, "grade")), "年级段字典是 {value,label},不是平台通用的 {k,v}"); assertEquals("profession.subjects", param(def, "subject").getOptionsFrom()); assertEquals(Arrays.asList("subId", "subName"), optionsKeysOf(param(def, "subject")), "学科字典是 {subId,subName}"); // 是否公开:单选按钮组,默认「公开」(模块页 ifOpen 初值 1) ToolParam ifOpen = param(def, "ifOpen"); assertEquals("radio", ifOpen.getWidget()); assertEquals(Arrays.asList("1", "0"), ifOpen.getEnumValues()); assertEquals(Arrays.asList("公开", "不公开"), ifOpen.getEnumLabels()); assertEquals("1", ifOpen.getDefaultValue()); // 正文:多行文本,占位文案与模块页的富文本编辑器一致 assertEquals("textarea", param(def, "remark").getWidget()); assertEquals("请输入正文内容…", param(def, "remark").getPlaceholderText()); // 附件:上传接口与其余模块**不同**(file 域,不是 /api/base/FileUpload)。 // 注意这里断言的是「模块内相对路径」的声明,最终下发值(带 /profession 前缀)由 // everyPublishToolUploadPathMatchesItsModuleRealEndpoint 兜住。 ToolParam fileIds = param(def, "fileIds"); assertEquals("file", fileIds.getWidget()); assertEquals(",", fileIds.getJoinSeparator(), "fileIds 在 wire 上是逗号串"); assertEquals("/api/file/Upload", fileIds.getUploadPathOverride()); } /** * 栏目动态字段(contents)的代取声明。 * *

这组字段由**栏目配置**决定、构建期枚举不出来 —— 模块页是 * {@code GET /api/moudule/Getdatelist?SerID=<栏目>} 取回后就地渲染, * 提交时打包成 {@code contents}。助手必须走同一条路,否则发到有自定义字段的栏目会缺数据。 */ @Test void professionPublishDeclaresColumnDynamicFields() { ToolDef def = registry.get("profession.publish"); assertEquals("profession.moduleFields", def.getExtraFieldsFrom()); assertEquals("contents", def.getExtraFieldsParam()); assertEquals("remark", def.getExtraFieldsBefore(), "模块页把栏目字段排在正文之前,不锚定会掉到附件后面"); assertEquals("moduleId", def.getExtraFieldsDepends().get("SerID"), "模块侧查询参数叫 SerID,表单字段叫 moduleId —— 必须显式映射"); assertEquals("profession.areaTree", def.getExtraFieldsAreaFrom(), "级联字段(province_city)要能取到区划树"); assertTrue(param(def, "contents").isHidden(), "contents 由表单打包生成,不作为字段渲染、也不让模型填"); // 代取地址要带模块真实前缀,且把依赖留成占位符(由前端替换后再发) ToolDef dict = registry.get("profession.moduleFields"); assertEquals("/api/moudule/Getdatelist", dict.getPath()); assertTrue(param(dict, "SerID").isRequired(), "栏目字段接口必须指定栏目"); BrowserToolRequest.BrowserCall call = executor.prepareOptionCall(dict, java.util.Collections.singletonList("SerID")); assertNotNull(call, "代取描述要能生成"); assertEquals("/profession/api/moudule/Getdatelist?SerID={{SerID}}", call.getPathAndQuery()); // 附件与代取地址都用「配置里的真实前缀」,而不是模块 key 拼出来的 assertEquals("/profession", AgentOrchestrator.pathPrefix("profession", "http://base.yunhaixingtong.com/profession")); } /* ==================== 发布类工具的「说明词」不变量 ==================== */ /** * 凡是「叫什么就发什么」的发布工具({@code *.publish}),说明里都必须有 * 「缺失就直接调用本工具…不要用文字反复追问」。 * *

起因(用户反馈:「我说我要发布专业发展,没到表单,先让我选哪个栏目,答完表单里 * 又要选一次所属栏目,一件事问了两遍」):五个发布工具里有四个都写了这句, * 唯独 {@code profession.publish} 只写了「调用前必须确认所属栏目 moduleId 与标题」—— * 模型读到「必须确认」,于是先反问用户要发到哪个栏目,答完才弹表单。 * *

这类问题的根子在**说明词**而不是代码:工具说明是模型唯一的施工图, * 「必须确认」后面缺了那句「缺失就直接调用」,模型就只好照字面去口头确认。 * 因此在契约测试里钉住「只写 {@code xxx.publish} 的都要带上这句」, * 将来新增发布工具漏写会被直接测出来。 * *

为什么是「名字以 {@code .publish} 结尾」而不是「所有 fullForm 工具」: * {@code notice.saveDraft} / {@code homework.schedule} 这类变体不在此列 —— * 它们是「同一个发布动作的另一种落点」,由发布工具自己的说明指向它们, * 单独调用时也不该反过来抢走发布意图(用户说「发布作业」要落到 {@code homework.publish})。 */ @Test void everyPublishToolTellsTheModelNotToKeepAskingInWords() { int checked = 0; for (ToolDef def : registry.all()) { if (!def.getName().endsWith(".publish")) { continue; } checked++; assertTrue(def.getDescription() != null && def.getDescription().contains("不要用文字反复追问"), "发布工具 " + def.getName() + " 的说明缺少「不要用文字反复追问」——" + "模型会照字面先反问用户要发到哪个栏目/填什么,把一件事拆成两轮。" + "实际说明:" + def.getDescription()); } assertEquals(6, checked, "注册表里应有 6 个发布工具(公告/通知公告/宿舍/活动/作业/专业发展)"); } /** * 通知公告(NoticeFront,前缀 /notice)与「校园公告」({@code campusnotice}) 是**两个应用**, * 发布字段截然不同。这里把 NoticeFront 的 {@code NewNotice.vue#doAdd} 请求体逐项钉死, * 防止两套字段互相串。 * *

历史教训:对齐任何模块之前,先确认自己打开的是**哪个**前端工程 —— 这两个工程 * 路由与页面名几乎同名(都是 /list、/list/new、已收到/已发送),极容易拿错源码。 */ @Test void noticeFrontPublishOwnsItsFieldSetAndDoesNotBorrowTheCampusAnnouncementOne() { ToolDef def = registry.get("notify.publish"); assertNotNull(def, "通知公告的发布工具应当注册"); assertEquals("notice", def.getModule()); assertEquals("/api/notice/Create", def.getPath()); List names = def.getParams().stream().map(ToolParam::getName).collect(Collectors.toList()); assertEquals(Arrays.asList("title", "content", "deptId", "receiveList", "fileIds", "timingSendTime"), names, "字段与顺序对齐 NoticeFront/src/views/notice/NewNotice.vue#doAdd"); // 收件人是「用户」:v-model 为 "all" 或用户 id 逗号串(components/UserCheckInput.vue) ToolParam receive = param(def, "receiveList"); assertNotNull(receive, "通知公告按用户下发,必须有 receiveList"); // ★ 2026-10-14:控件换成模块页同款的自绘组件(radio 全部/指定 + 选人弹窗), // 故这里是**显式** .as("userpicker"),不再靠 type/optionsFrom 推断。 assertEquals("userpicker", receive.resolveWidget(), "通知对象用模块页同款 UserCheckInput(radio 全部用户/指定用户 + 选人弹窗)"); assertEquals(",", receive.getJoinSeparator()); assertEquals("all", receive.getAllToken(), "勾「全部用户」提交的就是 all"); assertEquals("notify.schoolAll", receive.getOptionsFrom(), "弹窗里的组织树仍由 notify.schoolAll 代取(前端取**原始嵌套树**,不拍平)"); // 正文换成富文本(模块页 NewNotice.vue 用 wangEditor v5),并带上「插入图片」的两个地址 ToolParam content = param(def, "content"); assertEquals("richtext", content.resolveWidget(), "正文用富文本编辑器"); assertEquals("/api/file/UploadImage", content.getRichTextUploadOverride(), "图片上传是独立接口(不是附件的 FileUpload);只写相对路径,前缀由编排层拼"); assertEquals("/api/file/Preview?id={id}", content.getRichTextPreviewOverride(), "正文内图片必须用 Preview(inline),Down 是 attachment 内嵌显示不出来"); // 校园公告那一套字段一个都不该出现在这里 assertNull(param(def, "pushScopes"), "pushScopes(推送范围)是校园公告的字段"); assertNull(param(def, "receiptFlag"), "「学员阅读回执」是校园公告的字段"); assertNull(param(def, "publishType"), "「发布方式 / 审批」是校园公告的字段"); assertNull(param(def, "flow"), "审批流是校园公告的字段"); assertNull(param(def, "deptName"), "模块页不发 deptName(校园公告才发镜像字段)"); assertEquals("notify.deptDics", param(def, "deptId").getOptionsFrom()); assertTrue(def.isFormAll(), "发布表单要渲染全部字段(fullForm)"); /* ★ 代取地址必须带**模块真实前缀** —— 这一条**只能在后端测**: * mock 的 optionCall 一律写裸 /api/*(靠 vite 的 /api 代理转到 mock), * 本地真机脚本断言不到前缀,于是「绑错前缀」这类事故只能靠这里兜住。 * 模块 key 是 notice、配置里的地址是 .../notice ⇒ 前缀 /notice。 */ BrowserToolRequest.BrowserCall deptCall = executor.prepareOptionCall(registry.get("notify.deptDics"), java.util.Collections.emptyList()); assertNotNull(deptCall, "发文部门字典要能生成代取描述"); assertEquals("/notice/api/dict/List?type=1", deptCall.getPathAndQuery(), "push 了 fix(type=1) 也要在代取地址里带上,且前缀是 /notice"); BrowserToolRequest.BrowserCall userCall = executor.prepareOptionCall(registry.get("notify.schoolAll"), java.util.Collections.emptyList()); assertNotNull(userCall, "组织树要能生成代取描述"); assertEquals("/notice/api/base/GetSchoolAll", userCall.getPathAndQuery()); } /** * 通知公告发布表单的**提交形态**(浏览器代执行的最后一步)。 * *

表单里用户是「多选用户」,而模块的 {@code NoticeRequest.receiveList} 在 wire 上必须是 * 逗号串({@code NewNotice.vue} 的 {@code UserCheckInput} v-model 就是 {@code "101,102"}); * 这一步由 {@code ToolExecutor#normalizeJsonArgs} 在**代执行前**完成, * 漏了就是「页面上选得到、提交过去判非法」。 * *

本地真机脚本抓不到这个差异:mock 的 {@code browserCall.body} 是原样 {@code JSON.stringify(args)}, * 于是真机看到的是数组 {@code ["101","102"]}、真实后端发的是 {@code "101,102"} —— * 又一次「夹具与真实形态不一致」。所以断言放在这里。 */ @Test void notifyPublishSubmitsCommaJoinedReceiveListAtTheModuleUrl() { ToolDef def = registry.get("notify.publish"); Map args = new LinkedHashMap<>(); args.put("title", "关于本周晚自习调整的通知"); args.put("content", "各位老师:经学校研究决定……"); args.put("deptId", "D003"); args.put("receiveList", Arrays.asList("101", "102")); args.put("fileIds", new ArrayList<>()); BrowserToolRequest.BrowserCall call = executor.prepareBrowserCall(def, args); assertEquals("POST_JSON", call.getMethod()); assertEquals("/notice/api/notice/Create", call.getPathAndQuery(), "代执行提交要打模块真实前缀(配置地址是 .../notice)"); com.alibaba.fastjson.JSONObject body = com.alibaba.fastjson.JSON.parseObject(call.getBody()); assertEquals("关于本周晚自习调整的通知", body.getString("title")); assertEquals("D003", body.getString("deptId")); assertEquals("101,102", body.getString("receiveList"), "多选在 wire 上是逗号串,不是数组"); assertFalse(body.containsKey("fileIds"), "空的附件数组不该发出去(模块页空即不传)"); // 附件同理:多选文件 id 也要拼成逗号串,绝不能变成 ["11,12"] args.put("fileIds", Arrays.asList("11", "12")); assertEquals("11,12", com.alibaba.fastjson.JSON .parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("fileIds")); // 「全部用户」哨兵:只选它 ⇒ 发 all;与具体项同时选中 ⇒ 以具体项为准(哨兵折叠掉) args.put("receiveList", java.util.Collections.singletonList("all")); assertEquals("all", com.alibaba.fastjson.JSON .parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList")); args.put("receiveList", Arrays.asList("all", "101")); assertEquals("101", com.alibaba.fastjson.JSON .parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"), "哨兵与具体项互斥:同时选中时具体项优先"); /* ★ 2026-10-14:前端换成 UserCheckInput 后,表单里存的就是**字符串**("all" / "101,102"), * 不再是数组。这条短路极易出错:normalizeJsonArgs 的第二个循环会把「array 型参数却是标量」 * 包成 ["101,102"](整串塞进一个元素),而模块的 NoticeRequest.receiveList 是 String —— 判非法。 * 之所以现在没事,是因为第一个循环按 joinSeparator 拼过之后就 continue 了。 * 把这个「字符串原样透传」的口径钉住,防止谁把那个 continue 删掉。 */ args.put("receiveList", "101,102"); assertEquals("101,102", com.alibaba.fastjson.JSON .parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"), "userpicker 直接给逗号串时不能再被包成数组"); args.put("receiveList", "all"); assertEquals("all", com.alibaba.fastjson.JSON .parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList")); } /* ==================== 辅助 ==================== */ /** 字典返回对象里「值 / 显示名」取的是哪两个键(optionsKeys 的结果) */ private List optionsKeysOf(ToolParam p) { if (p == null) { return null; } return Arrays.asList(p.getOptionValueKey(), p.getOptionLabelKey()); } private ToolParam param(ToolDef def, String name) { if (def == null || def.getParams() == null) { return null; } for (ToolParam p : def.getParams()) { if (name.equals(p.getName())) { return p; } } return null; } /** 供将来表单字段断言使用(保持与 FormField 的耦合可见) */ @SuppressWarnings("unused") private FormField field(ToolDef def, String name) { ToolParam p = param(def, name); return p == null ? null : p.toFormField(); } @SuppressWarnings("unused") private List paramNames(ToolDef def) { List names = new java.util.ArrayList<>(); for (ToolParam p : def.getParams()) { names.add(p.getName()); } return names; } }