Files
yunhai-agent-service/src/test/java/com/yunhai/agent/FormFieldWidgetTest.java
T
lijing 006003c842
build / build-push (push) Successful in 1m15s
feat(表单): 五个模块正文换成模块页同款富文本,公告/宿舍的接收对象换成自绘控件
彻底对齐各模块「新建 / 发布」页里的两类控件(此前只有通知公告搬到位):

正文 → 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 同步改成新控件口径
2026-10-14 16:56:07 +08:00

265 lines
13 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
}
}