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)。 * *
背景(2026-10-10 截图实测):请假表单里「请假类型」渲染成了普通输入框, * 占位文字是「请假类型编号,来自 leave.types」。根因有两层: *
这正是 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 两个反向断言都是踩过的坑:
* ⚠️ 这里只断言**工具层声明**({@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());
}
}
*
*/
@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 的页签名与地址一一对应。
*
*