Files
yunhai-agent-service/src/test/java/com/yunhai/agent/ModuleFormParityTest.java
T
lijing c084c978aa
build / build-push (push) Successful in 2m1s
feat(通知公告): 发布表单支持模块页的富文本正文与选人控件
前端已把 NoticeFront 的 wangEditor 与自绘「通知对象」控件 1:1 搬进助手表单,
后端补上这两个控件所需的契约:

- ToolParam / FormField 新增:
  · pickerSources:选人弹窗的**数据源页签**(形如 {name, 模块内相对地址}),
    由 AgentOrchestrator 拼上模块前缀后下发(沿用「只写相对路径」的铁律);
  · richTextUploadPath / richTextPreviewPath:正文插图的上传与预览地址。
- AgentOrchestrator:把 resolveUploadPath 抽出通用形态 resolveModulePath(幂等、只接受
  模块内相对路径);buildForm 为 widget=userpicker 下发 pickerSources;
  optionCall 强制改写 widget 的白名单放行 userpicker(否则会被降级成 select)。
- NoticeTools:notify.publish 与 notify.editNotice 的 receiveList 改为 userpicker,
  两个数据源为「基础平台」/api/base/GetSchoolAll 与「自定义组」
  /api/contacts/GetGroupTree?type=1;content 走 richtext。

测试:NotifyIntentFormTest / IntentDirectFormTest 补 pickerSources 断言
(**只写相对路径**,不带前缀 —— 前缀拼错是本项目反复出过的坑),
ModuleFormParityTest 补 userpicker 取值口径断言(字符串原样透传,不得包成数组)。
mvn -o test:Tests run: 135, Failures: 0, Errors: 0,BUILD SUCCESS。
2026-10-14 15:19:24 +08:00

686 lines
39 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/**
* 「五个模块的发布页字段与助手表单完全对齐」的契约断言。
*
* <p>逐条钉住本轮从模块源码里抠出来的差异 —— 这些都是**会真的提交出问题**的地方,
* 不是风格问题:
* <ol>
* <li>公告 {@code publishType} 合法值是 {@code direct / approve}(存草稿走 {@code SaveDraft} 接口);</li>
* <li>活动 {@code category} 存的是分类**名称**(模块页写的是 {@code :value="String(c.v)"});</li>
* <li>请假 {@code fileIds} 是 {@code number[]},**不能**拼成逗号串;</li>
* <li>公告/宿舍/活动三处的 {@code deptName} 要按所选部门镜像过去;</li>
* <li>作业「发布」是两步(先落草稿 → 再发布/定时),附件是对象数组;</li>
* <li>公告审批流 {@code flow} 与教师树 {@code notice.teaTree}。</li>
* </ol>
*/
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<String, String> 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<String, Object> 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<String, Object> 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` 的配置。
*
* <p>请假的 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));
}
/**
* 六个模块的附件上传地址:逐条钉住**线上真实地址**(对照各模块发布页源码核出来的)。
*
* <p>这里是「用生产代码算、拿真实地址比」,而不是把拼接逻辑在测试里再抄一遍 ——
* 上一版测试就是自己拼 {@code pathPrefix(...) + "/api/base/FileUpload"},
* 于是 {@code profession.publish} 声明成 {@code /api/file/Upload} 却**从不经过前缀拼接**
* 这件事,测试根本看不见,线上直接 404。
*
* <p>各模块真实地址的出处:
* <ul>
* <li>公告 {@code NoticeUpload.vue}:{@code serviceHost + "/api/base/FileUpload"}</li>
* <li>宿舍/活动/请假 {@code AttachmentUpload.vue}:同上</li>
* <li>作业 {@code AttachmentUpload.vue}:{@code serviceHost + "/api/file/Upload"}(走 file 域)</li>
* <li>专业发展 {@code RecordEdit.vue} 的 {@code uploadAction = fileUploadUrl} = {@code serviceHost + "/api/file/Upload"}</li>
* </ul>
* 其中 {@code serviceHost} 由各模块 {@code host.config.ts} 推出 = {@code origin + 当前路径首段},
* 正是 {@code agent.modules} 里配置的那个路径前缀。
*/
@Test
void everyPublishToolUploadPathMatchesItsModuleRealEndpoint() {
Map<String, String[]> 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<String, String[]> 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] + ")");
}
}
/**
* 通用不变量:**任何一个**附件字段的最终上传地址都必须以它所属模块的路由前缀开头。
*
* <p>不写死模块清单,以后新增模块/新增附件字段会自动被覆盖 ——
* 这正是「拼错前缀」和「压根没拼前缀」两类 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<String, Object> 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} 逐项一致。
*
* <p>★ 这条钉的是一个**真实提交出错**的差异:早先助手只发 {@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)的代取声明。
*
* <p>这组字段由**栏目配置**决定、构建期枚举不出来 —— 模块页是
* {@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}),说明里都必须有
* 「缺失就直接调用本工具…不要用文字反复追问」。
*
* <p>起因(用户反馈:「我说我要发布专业发展,没到表单,先让我选哪个栏目,答完表单里
* 又要选一次所属栏目,一件事问了两遍」):五个发布工具里有四个都写了这句,
* 唯独 {@code profession.publish} 只写了「调用前必须确认所属栏目 moduleId 与标题」——
* 模型读到「必须确认」,于是先反问用户要发到哪个栏目,答完才弹表单。
*
* <p>这类问题的根子在**说明词**而不是代码:工具说明是模型唯一的施工图,
* 「必须确认」后面缺了那句「缺失就直接调用」,模型就只好照字面去口头确认。
* 因此在契约测试里钉住「只写 {@code xxx.publish} 的都要带上这句」,
* 将来新增发布工具漏写会被直接测出来。
*
* <p>为什么是「名字以 {@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} 请求体逐项钉死,
* 防止两套字段互相串。
*
* <p>历史教训:对齐任何模块之前,先确认自己打开的是**哪个**前端工程 —— 这两个工程
* 路由与页面名几乎同名(都是 /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<String> 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());
}
/**
* 通知公告发布表单的**提交形态**(浏览器代执行的最后一步)。
*
* <p>表单里用户是「多选用户」,而模块的 {@code NoticeRequest.receiveList} 在 wire 上必须是
* <b>逗号串</b>({@code NewNotice.vue} 的 {@code UserCheckInput} v-model 就是 {@code "101,102"});
* 这一步由 {@code ToolExecutor#normalizeJsonArgs} 在**代执行前**完成,
* 漏了就是「页面上选得到、提交过去判非法」。
*
* <p>本地真机脚本抓不到这个差异:mock 的 {@code browserCall.body} 是原样 {@code JSON.stringify(args)},
* 于是真机看到的是数组 {@code ["101","102"]}、真实后端发的是 {@code "101,102"} ——
* <b>又一次「夹具与真实形态不一致」</b>。所以断言放在这里。
*/
@Test
void notifyPublishSubmitsCommaJoinedReceiveListAtTheModuleUrl() {
ToolDef def = registry.get("notify.publish");
Map<String, Object> 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<String> 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<String> paramNames(ToolDef def) {
List<String> names = new java.util.ArrayList<>();
for (ToolParam p : def.getParams()) {
names.add(p.getName());
}
return names;
}
}