build / build-push (push) Successful in 2m1s
前端已把 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。
686 lines
39 KiB
Java
686 lines
39 KiB
Java
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;
|
||
}
|
||
}
|