把 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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user