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」。根因有两层: *

    *
  1. 它在工具定义里是 {@code integer},本应落到数字控件,但前端没有 {@code number} 分支;
  2. *
  3. 它的可选项来自字典工具 {@code leave.types},字典没取到时不该静默退化 —— * 必须把 {@link FormField#getOptionsFrom()} 一并下发,前端才能提示「可先手填编号」。
  4. *
*/ 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()); } /** * ④ 内置兜底:声明了字典来源、而实时字典取不到时,仍应是下拉框,而不是让人填编号的数字框。 *

这正是 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> items = (java.util.List>) 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()); } /** * ⑪ 富文本正文:控件形态 + **插图地址必须原样下发**,且「不声明 = 空」。 * *

两个反向断言都是踩过的坑: *

*/ @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 的页签名与地址一一对应。 * *

⚠️ 这里只断言**工具层声明**({@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()); } }