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; /** * 「五个模块的发布页字段与助手表单完全对齐」的契约断言。 * *
逐条钉住本轮从模块源码里抠出来的差异 —— 这些都是**会真的提交出问题**的地方, * 不是风格问题: *
请假的 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。 * *
各模块真实地址的出处: *
不写死模块清单,以后新增模块/新增附件字段会自动被覆盖 ——
* 这正是「拼错前缀」和「压根没拼前缀」两类 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 ★ 这条钉的是一个**真实提交出错**的差异:早先助手只发 {@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 表单里用户是「多选用户」,而模块的 {@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