build / build-push (push) Successful in 1m15s
彻底对齐各模块「新建 / 发布」页里的两类控件(此前只有通知公告搬到位):
正文 → wangEditor(widget=richtext)
· 校园公告 content、宿舍通知 content(发布 + 编辑)、家校活动 content(发布 + 编辑)、
作业 description
· 教师专业发展 remark:只声明形态、**不声明** .richText(...) —— 该模块 file 域只有
Upload/Preview/Down(没有 UploadImage),模块页 RecordEdit.vue 也没配 uploadImage,
所以下发空地址,前端据此把工具栏的「图片」组整组摘掉(模块页就是这个形态)
接收对象 → 自绘控件(widget=scopepicker / roompicker)
· 校园公告 pushScopes:换成 ClassScopeInput 同款(全部班级 / 指定班级 + 弹窗班级树);
提交口径不变,仍是 [{scopeType,scopeId,scopeName}]
· 宿舍通知 receiveList:换成模块页「选择接收宿舍」弹窗同款(左楼栋 / 右按楼层分组的房间);
v-model 是字符串(房间 id 逗号串或 ALL 哨兵),与模块 receiveList 的真实契约一致
基建
· ToolParam 新增 richText(upload,preview) / scopePicker(type) / roomPicker() /
pickerSource(页签名, 模块内相对路径);对应 FormField 四个字段
· AgentOrchestrator:新增 resolveModulePath 通用形态,richtext 与 pickerSources 的地址
一律只写模块内相对路径、前缀在这里拼;richtext **没有兜底**(不声明就是空)
· 带 optionCall 的字段会被改写成 select 的白名单改用 isSelfDrawnPicker()
(原先是硬编码 userpicker),并放开为 public 供契约测试复用生产口径
测试(139 tests, 0 failures)
· ModuleFormParityTest 新增两条通用不变量:正文图片地址与弹窗数据源都必须带模块前缀、
不许自己带前缀、不许双斜杠;另加「专业发展不得拿到兜底上传地址」
· FormFieldWidgetTest 新增 richtext(含「不声明 = 空」反向断言)与自绘控件透传两条
· IntentDirectFormTest / DormActivityPublishIntentTest 同步改成新控件口径
265 lines
13 KiB
Java
265 lines
13 KiB
Java
package com.yunhai.agent;
|
||
|
||
import com.yunhai.agent.tool.FormField;
|
||
import com.yunhai.agent.tool.ToolDef;
|
||
import com.yunhai.agent.tool.ToolParam;
|
||
import com.yunhai.agent.tool.defs.LeaveTools;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||
|
||
/**
|
||
* 表单字段「控件形态」的回归断言(纯单元,不依赖 Spring)。
|
||
*
|
||
* <p>背景(2026-10-10 截图实测):请假表单里「请假类型」渲染成了普通输入框,
|
||
* 占位文字是「请假类型编号,来自 leave.types」。根因有两层:
|
||
* <ol>
|
||
* <li>它在工具定义里是 {@code integer},本应落到数字控件,但前端没有 {@code number} 分支;</li>
|
||
* <li>它的可选项来自字典工具 {@code leave.types},字典没取到时不该静默退化 ——
|
||
* 必须把 {@link FormField#getOptionsFrom()} 一并下发,前端才能提示「可先手填编号」。</li>
|
||
* </ol>
|
||
*/
|
||
class FormFieldWidgetTest {
|
||
|
||
/** ① 整数参数 → number 控件;字典来源必须一并下发(前端据此在字典失败时给提示) */
|
||
@Test
|
||
void integerParamWithDictSourceRendersNumberAndCarriesOptionsFrom() {
|
||
ToolParam p = ToolParam.required("leaveType", "integer", "请假类型编号,来自 leave.types")
|
||
.label("请假类型").optionsFrom("leave.types");
|
||
|
||
FormField f = p.toFormField();
|
||
|
||
assertEquals("number", f.getWidget(), "integer 参数应渲染成数字控件");
|
||
assertEquals("leave.types", f.getOptionsFrom(), "动态选项来源必须一并下发");
|
||
assertTrue(f.getOptions().isEmpty(), "未取到字典时 options 应为空(前端据此提示)");
|
||
}
|
||
|
||
/** ② 静态枚举 → select,且不应带 optionsFrom(否则前端会误报「字典没取到」) */
|
||
@Test
|
||
void enumParamRendersSelectWithoutOptionsFrom() {
|
||
ToolParam p = ToolParam.required("level", "string", "级别")
|
||
.label("级别").options(new String[]{"1", "2"}, new String[]{"普通", "重要"});
|
||
|
||
FormField f = p.toFormField();
|
||
|
||
assertEquals("select", f.getWidget());
|
||
assertNull(f.getOptionsFrom(), "静态枚举没有动态来源,optionsFrom 应为 null");
|
||
assertEquals(2, f.getOptions().size());
|
||
}
|
||
|
||
/** ③ 日期与长文本仍按参数名推断(防止改动上面两条时把推断逻辑带歪) */
|
||
@Test
|
||
void nameBasedWidgetInferenceStillWorks() {
|
||
assertEquals("datetime",
|
||
ToolParam.required("startDate", "string", "开始时间").toFormField().getWidget());
|
||
assertEquals("textarea",
|
||
ToolParam.required("remarks", "string", "请假事由").toFormField().getWidget());
|
||
}
|
||
|
||
/**
|
||
* ④ 内置兜底:声明了字典来源、而实时字典取不到时,仍应是下拉框,而不是让人填编号的数字框。
|
||
* <p>这正是 2026-10-10 反馈「请假类型怎么变成数字框了,还是 1 这种」的修复点 ——
|
||
* 光有选项来源不够,还得有可用的默认选项,控件才立得住。
|
||
*/
|
||
@Test
|
||
void fallbackOptionsKeepSelectWidgetWhenDictUnavailable() {
|
||
ToolParam p = ToolParam.required("leaveType", "integer", "请假类型")
|
||
.label("请假类型")
|
||
.fallbackOptions(new String[]{"1", "2"}, new String[]{"病假", "事假及其他"})
|
||
.optionsFrom("leave.types");
|
||
|
||
FormField f = p.toFormField();
|
||
|
||
assertEquals("select", f.getWidget(), "有兜底选项时必须渲染成下拉框");
|
||
assertEquals(2, f.getOptions().size());
|
||
assertEquals("1", f.getOptions().get(0).get("value"));
|
||
assertEquals("病假", f.getOptions().get(0).get("label"));
|
||
assertEquals("事假及其他", f.getOptions().get(1).get("label"));
|
||
assertTrue(f.isOptionsFallback(), "必须标记为兜底,前端才能提示「自定义项没取到」");
|
||
assertEquals("leave.types", f.getOptionsFrom(), "实时字典来源要保留,取到后覆盖兜底");
|
||
}
|
||
|
||
/** ⑤ 权威枚举优先于兜底:两者都配时兜底不生效,也不应被误标为兜底 */
|
||
@Test
|
||
void explicitEnumWinsOverFallback() {
|
||
ToolParam p = ToolParam.required("status", "integer", "审批结论")
|
||
.options(new String[]{"1"}, new String[]{"通过"})
|
||
.fallbackOptions(new String[]{"9"}, new String[]{"兜底项"});
|
||
|
||
FormField f = p.toFormField();
|
||
|
||
assertEquals(1, f.getOptions().size());
|
||
assertEquals("通过", f.getOptions().get(0).get("label"));
|
||
assertFalse(f.isOptionsFallback());
|
||
}
|
||
|
||
/** ⑥ 真实工具定义回归:请假类型必须是带中文选项的**单选按钮组**(对齐模块页 RadioGroup) */
|
||
@Test
|
||
void leaveSubmitTypeIsARadioInRealToolDefinition() {
|
||
ToolDef def = LeaveTools.defs().stream()
|
||
.filter(d -> "leave.submit".equals(d.getName()))
|
||
.findFirst()
|
||
.orElseThrow(() -> new AssertionError("leave.submit 工具定义丢失"));
|
||
|
||
ToolParam leaveType = def.getParams().stream()
|
||
.filter(p -> "leaveType".equals(p.getName()))
|
||
.findFirst()
|
||
.orElseThrow(() -> new AssertionError("leaveType 参数丢失"));
|
||
|
||
FormField f = leaveType.toFormField();
|
||
|
||
// ★ 2026-10-13:从下拉改为单选按钮组 —— 模块页 LeaveApply.vue 用的就是 RadioGroup,
|
||
// 且进页面即选中第一项,助手表单要一致。
|
||
assertEquals("radio", f.getWidget());
|
||
assertTrue(f.isAutoPickFirst(), "选项就绪后应自动选中第一项(模块页的默认行为)");
|
||
assertEquals("leave.types", f.getOptionsFrom(), "选项仍走学校自定义的实时字典");
|
||
assertEquals(2, f.getOptions().size());
|
||
assertEquals("病假", f.getOptions().get(0).get("label"));
|
||
assertEquals("事假及其他", f.getOptions().get(1).get("label"));
|
||
}
|
||
|
||
/** ⑦ 单选控件的值与 label 必须同类型(前端 Radio 用严格比较判定选中态) */
|
||
@Test
|
||
void radioOptionValuesStayStringsForStrictComparison() {
|
||
ToolParam p = ToolParam.required("leaveType", "integer", "请假类型")
|
||
.radio().autoPickFirst()
|
||
.fallbackOptions(new String[]{"1", "2"}, new String[]{"病假", "事假及其他"});
|
||
|
||
FormField f = p.toFormField();
|
||
|
||
assertEquals("radio", f.getWidget());
|
||
assertEquals("1", f.getOptions().get(0).get("value"), "选项值必须是字符串,否则前端单选按钮选不上");
|
||
assertTrue(f.isAutoPickFirst());
|
||
}
|
||
|
||
/** ⑧ 勾选框:连续请假用 switch(模块页是 Checkbox,不是「是/否」下拉) */
|
||
@Test
|
||
void switchWidgetCarriesOnOffValues() {
|
||
ToolParam p = ToolParam.optional("continuousLeave", "integer", "是否连续请假")
|
||
.switchOf("1", "0");
|
||
|
||
FormField f = p.toFormField();
|
||
|
||
assertEquals("switch", f.getWidget());
|
||
assertEquals("1", f.getSwitchOn());
|
||
assertEquals("0", f.getSwitchOff());
|
||
}
|
||
|
||
/** ⑨ 快捷时段:三元组按「标签, 开始, 结束」解析,个数不是 3 的倍数要当场报错 */
|
||
@Test
|
||
void quickPeriodsParsesTriplesAndRejectsBadArity() {
|
||
ToolParam p = ToolParam.required("startDate", "string", "开始时间")
|
||
.quickPeriods("startDate", "endDate",
|
||
"全天", "08:00:00", "18:00:00",
|
||
"上午", "08:00:00", "12:00:00",
|
||
"下午", "12:00:00", "18:00:00");
|
||
|
||
FormField f = p.toFormField();
|
||
|
||
assertEquals("startDate", f.getQuickPeriods().get("start"));
|
||
assertEquals("endDate", f.getQuickPeriods().get("end"));
|
||
@SuppressWarnings("unchecked")
|
||
java.util.List<java.util.Map<String, String>> items =
|
||
(java.util.List<java.util.Map<String, String>>) f.getQuickPeriods().get("items");
|
||
assertEquals(3, items.size());
|
||
assertEquals("全天", items.get(0).get("label"));
|
||
assertEquals("08:00:00", items.get(0).get("start"));
|
||
assertEquals("18:00:00", items.get(0).get("end"));
|
||
|
||
org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
|
||
() -> ToolParam.required("s", "string", "").quickPeriods("s", "e", "仅一个标签"));
|
||
}
|
||
|
||
/** ⑩ 多行文本框的行数与字数上限也要下发(对齐模块页「请假事由」的 4 行 / 1000 字) */
|
||
@Test
|
||
void textareaRowsAndMaxLengthArePassedThrough() {
|
||
FormField f = ToolParam.required("remarks", "string", "请假事由")
|
||
.as("textarea").rows(4).maxLength(1000).toFormField();
|
||
|
||
assertEquals("textarea", f.getWidget());
|
||
assertEquals(4, f.getRows().intValue());
|
||
assertEquals(1000, f.getMaxLength().intValue());
|
||
}
|
||
|
||
/**
|
||
* ⑪ 富文本正文:控件形态 + **插图地址必须原样下发**,且「不声明 = 空」。
|
||
*
|
||
* <p>两个反向断言都是踩过的坑:
|
||
* <ul>
|
||
* <li>地址**没有兜底** —— 像专业发展那种模块编辑器压根没配 uploadImage,
|
||
* 工具里不写 {@code .richText(...)} 就不能凭空给一个默认值,
|
||
* 否则前端会长出模块页没有的「图片」按钮;</li>
|
||
* <li>{@code .as("richtext")} 与 {@code .richText(...)} 是两件事:前者管形态,
|
||
* 后者管地址。只写前者(专业发展的写法)地址必须是 null。</li>
|
||
* </ul>
|
||
*/
|
||
@Test
|
||
void richTextWidgetCarriesUploadAndPreviewPathsOnlyWhenDeclared() {
|
||
FormField withImages = ToolParam.required("content", "string", "公告正文")
|
||
.as("richtext")
|
||
.richText("/api/file/UploadImage", "/api/file/Preview?id={id}")
|
||
.toFormField();
|
||
|
||
assertEquals("richtext", withImages.getWidget());
|
||
assertEquals("/api/file/UploadImage", withImages.getRichTextUploadPath());
|
||
assertEquals("/api/file/Preview?id={id}", withImages.getRichTextPreviewPath());
|
||
|
||
// 只声明形态、不声明地址 —— 地址必须为空(前缀由编排层拼,不在这里兜底)
|
||
FormField noImages = ToolParam.optional("remark", "string", "正文内容")
|
||
.as("richtext")
|
||
.toFormField();
|
||
|
||
assertEquals("richtext", noImages.getWidget());
|
||
assertNull(noImages.getRichTextUploadPath(), "不声明 .richText(...) 时上传地址必须是空,不能兜底");
|
||
assertNull(noImages.getRichTextPreviewPath());
|
||
}
|
||
|
||
/**
|
||
* ⑫ 自绘「接收对象」控件:三种形态各自透传,且 pickerSources 的页签名与地址一一对应。
|
||
*
|
||
* <p>⚠️ 这里只断言**工具层声明**({@code ToolParam.pickerSources} 的原始
|
||
* {@code {页签名, 模块内相对路径}}),不断言 {@code FormField.getPickerSources()} ——
|
||
* 后者的 {@code call} 是在**编排层**把相对路径拼上前缀后才建出来的,
|
||
* 纯单元测试里 {@code toFormField()} 根本不会填它(误断言会拿到 null)。
|
||
* 「拼好前缀之后」的那一层由 {@code ModuleFormParityTest} 的通用不变量兜住。
|
||
*/
|
||
@Test
|
||
void selfDrawnPickersPassThroughWidgetAndSources() {
|
||
ToolParam scope = ToolParam.optional("pushScopes", "array", "接收班级")
|
||
.label("接收班级").scopePicker(4)
|
||
.pickerSource("班级", "/api/base/getStuTree?toUser=false")
|
||
.allOption("ALL", "全部班级");
|
||
|
||
FormField scopeField = scope.toFormField();
|
||
assertEquals("scopepicker", scopeField.getWidget());
|
||
assertEquals(Integer.valueOf(4), scopeField.getScopeType());
|
||
assertEquals("ALL", scopeField.getAllToken());
|
||
assertEquals("全部班级", scopeField.getAllTokenLabel());
|
||
assertEquals(1, scope.getPickerSources().size(), "scopepicker 是单页签(班级)");
|
||
assertEquals("班级", scope.getPickerSources().get(0)[0]);
|
||
assertEquals("/api/base/getStuTree?toUser=false", scope.getPickerSources().get(0)[1],
|
||
"只写模块内相对路径,前缀留给编排层");
|
||
|
||
ToolParam room = ToolParam.optional("receiveList", "array", "接收宿舍")
|
||
.label("接收宿舍").roomPicker()
|
||
.pickerSource("楼栋", "/api/dorm/BuildingList")
|
||
.pickerSource("房间", "/api/dorm/RoomList")
|
||
.allOption("ALL", "全部宿舍");
|
||
|
||
FormField roomField = room.toFormField();
|
||
assertEquals("roompicker", roomField.getWidget());
|
||
assertEquals("ALL", roomField.getAllToken());
|
||
assertEquals(2, room.getPickerSources().size(), "房间弹窗是「楼栋 + 房间」两个数据源");
|
||
assertEquals("楼栋", room.getPickerSources().get(0)[0]);
|
||
assertEquals("/api/dorm/BuildingList", room.getPickerSources().get(0)[1]);
|
||
assertEquals("房间", room.getPickerSources().get(1)[0]);
|
||
assertEquals("/api/dorm/RoomList", room.getPickerSources().get(1)[1]);
|
||
|
||
// 没声明数据源的普通字段不能凭空长出 pickerSources
|
||
assertNull(ToolParam.required("title", "string", "标题").getPickerSources());
|
||
}
|
||
}
|