feat(form): 发布表单与各模块「新建发布」页逐字段对齐(含审批流与作业两步发布)
build / build-push (push) Successful in 1m2s

把 5 个模块「新建/发布」页的字段与提交体逐个核对后,补齐此前缺失或形态不符的部分:

- 部门名镜像:公告/宿舍/活动三处在模块页是「字典 id + 反查名称」一起提交。
  新增 mirrorLabelOf 机制 —— 该字段不渲染控件、不进模型 schema,提交时按源字段
  选中项的显示名填充(deptName)
- 公告审批流:新增 flow 控件(widget=flow)+ 教师组织树字典 notice.teaTree,
  提交 flow:[{name,vote,approverTeaIds}];publishType 值域修正为 direct/approve;
  存草稿改用模块自己的独立接口 notice.saveDraft(POST /api/notice/SaveDraft)
- 活动分类:category 提交的是**分类名称**(模块页 :value="String(c.v)"),
  optionsKeys 由 ("k","v") 改为 ("v","v")
- 请假附件:fileIds 在 HomeLeave 是 number[](JSON 数组),去掉逗号串拼接;
  连续请假按 continuousLeaveNum 逐天展开成 continuousLeaveTime[](服务端/浏览器同源)
- 作业发布(整块新增):homework.publish / schedule / published / drafts /
  teachingSubjects / classTree。模块没有一步发布的接口,用 ToolDef.then 声明两步提交
  (Create 落草稿 → Publish | Schedule);附件是对象数组
  attachments:[{fileId,fileUrl,fileType,fileName,sort}],上传走 file 域
  /coursehomework/api/file/Upload
- 执行器:新增 followUp 两步提交(浏览器侧同源)、数组参数给标量时包成单元素数组、
  首步响应 data 取资源 id(对象形态取 .id,避免拼出 /homeworks/{"id":1}/publish)
- 新增 ModuleFormParityTest(13 项)逐条锁住上述契约

回归:mvn clean package → Tests run: 103, Failures: 0, Errors: 0, BUILD SUCCESS
真机:headless Chrome 走「表单 → 浏览器代执行 → 模块接口」,50 项断言全绿
This commit is contained in:
lijing
2026-10-13 11:29:03 +08:00
parent 322d589b3e
commit c16356db6e
14 changed files with 1318 additions and 52 deletions
@@ -0,0 +1,273 @@
package com.yunhai.agent;
import com.yunhai.agent.config.AgentSettings;
import com.yunhai.agent.model.BrowserToolRequest;
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.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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");
settings.getModules().put("dormnotice", "http://base.yunhaixingtong.com/dormnotice");
settings.getModules().put("familyactivity", "http://base.yunhaixingtong.com/familyactivity");
settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave");
settings.getModules().put("coursehomework", "http://base.yunhaixingtong.com/coursehomework");
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("/coursehomework/api/file/Upload", att.getUploadPathOverride(),
"作业模块的附件上传走 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");
}
/* ==================== ⑥ 附件控件渲染 ==================== */
@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);
}
/* ==================== 辅助 ==================== */
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;
}
}