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 同步改成新控件口径
449 lines
24 KiB
Java
449 lines
24 KiB
Java
package com.yunhai.agent;
|
||
|
||
import com.alibaba.fastjson.JSONArray;
|
||
import com.yunhai.agent.auth.AgentRole;
|
||
import com.yunhai.agent.config.AgentSettings;
|
||
import com.yunhai.agent.llm.LlmClient;
|
||
import com.yunhai.agent.llm.dto.ChatMessage;
|
||
import com.yunhai.agent.llm.dto.ChatResult;
|
||
import com.yunhai.agent.model.ChatRequest;
|
||
import com.yunhai.agent.model.ChatResponse;
|
||
import com.yunhai.agent.service.AgentAuditService;
|
||
import com.yunhai.agent.service.AgentOrchestrator;
|
||
import com.yunhai.agent.service.EventSink;
|
||
import com.yunhai.agent.service.IntentShortcut;
|
||
import com.yunhai.agent.service.SessionStore;
|
||
import com.yunhai.agent.tool.CallerContext;
|
||
import com.yunhai.agent.tool.FormField;
|
||
import com.yunhai.agent.tool.ToolExecutor;
|
||
import com.yunhai.agent.tool.ToolRegistry;
|
||
import org.junit.jupiter.api.BeforeEach;
|
||
import org.junit.jupiter.api.Test;
|
||
import org.mockito.Mockito;
|
||
import org.springframework.web.client.RestTemplate;
|
||
|
||
import java.util.ArrayList;
|
||
import java.util.Collections;
|
||
import java.util.LinkedHashSet;
|
||
import java.util.List;
|
||
import java.util.Set;
|
||
|
||
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;
|
||
import static org.mockito.ArgumentMatchers.any;
|
||
import static org.mockito.ArgumentMatchers.anyString;
|
||
import static org.mockito.Mockito.when;
|
||
|
||
/**
|
||
* 裸意图在编排层的端到端断言:**同一句话、不同身份 → 不同表单**,且**不经过大模型**。
|
||
*
|
||
* <p>覆盖已落地的意图:
|
||
* <ul>
|
||
* <li>「我要请假」→ 学生拿请假申请、教职工拿请假登记(帮学生请假);</li>
|
||
* <li>「我要发布校园公告」→ 教职工拿发布表单,学生落回模型;</li>
|
||
* <li>「我要发布通知公告」→ 同上,但落到**另一个应用**(通知公告,按用户下发)。</li>
|
||
* </ul>
|
||
*
|
||
* <p>这是本次修复的核心承诺:用户说「我要请假」应当**直接**看到表单,
|
||
* 而不是先被模型拿去查字典、或自行编造时间直接提交、或反过来追问用户。
|
||
* 因此测试里的 {@link LlmClient} 被设成「一旦被调用就失败」,用来证明这条链路真的没走模型。
|
||
*/
|
||
class IntentDirectFormTest {
|
||
|
||
private AgentSettings settings;
|
||
private ToolRegistry registry;
|
||
private SessionStore sessionStore;
|
||
private AgentOrchestrator orchestrator;
|
||
|
||
/** 被调用即失败的模型客户端:证明「意图直达」路径压根不碰大模型 */
|
||
private static final LlmClient EXPLODING_LLM = new LlmClient() {
|
||
@Override
|
||
public ChatResult chat(List<ChatMessage> messages, JSONArray tools) {
|
||
throw new AssertionError("裸意图(我要请假)不应调用大模型");
|
||
}
|
||
};
|
||
|
||
/** 正常应答的模型客户端:用于断言「非裸意图」确实回落到模型流程 */
|
||
private static final LlmClient ECHO_LLM = (messages, tools) -> {
|
||
ChatResult r = new ChatResult();
|
||
r.setContent("(模型回复)");
|
||
return r;
|
||
};
|
||
|
||
@BeforeEach
|
||
void setUp() {
|
||
settings = new AgentSettings();
|
||
settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave");
|
||
settings.getModules().put("campusnotice", "http://base.yunhaixingtong.com/campusnotice");
|
||
settings.getModules().put("notice", "http://base.yunhaixingtong.com/notice");
|
||
settings.getModules().put("dormnotice", "http://base.yunhaixingtong.com/dormnotice");
|
||
settings.getModules().put("familyactivity", "http://base.yunhaixingtong.com/familyactivity");
|
||
settings.getModules().put("profession", "http://base.yunhaixingtong.com/profession");
|
||
registry = new ToolRegistry(settings);
|
||
registry.init();
|
||
|
||
sessionStore = Mockito.mock(SessionStore.class);
|
||
SessionStore.SessionState state = new SessionStore.SessionState();
|
||
state.setSessionId("s-test");
|
||
state.setMessages(new ArrayList<>());
|
||
when(sessionStore.getOrCreate(any(), any(), any(), anyString())).thenReturn(state);
|
||
}
|
||
|
||
private AgentOrchestrator orchestrator(LlmClient llm) {
|
||
return new AgentOrchestrator(settings, llm, registry,
|
||
new ToolExecutor(settings, new RestTemplate()), sessionStore,
|
||
Mockito.mock(AgentAuditService.class), new IntentShortcut());
|
||
}
|
||
|
||
private CallerContext caller(AgentRole... roles) {
|
||
CallerContext c = new CallerContext();
|
||
Set<AgentRole> set = new LinkedHashSet<>();
|
||
Collections.addAll(set, roles);
|
||
c.setRoles(set);
|
||
c.setUserId("10008");
|
||
c.setUserName("测试用户");
|
||
return c;
|
||
}
|
||
|
||
private ChatResponse chat(LlmClient llm, String message, CallerContext caller) {
|
||
ChatRequest req = new ChatRequest();
|
||
req.setMessage(message);
|
||
return orchestrator(llm).chat(req, "姓名 测试用户", caller, null, EventSink.NOOP);
|
||
}
|
||
|
||
private FormField field(ChatResponse resp, String name) {
|
||
for (FormField f : resp.getForm().getFields()) {
|
||
if (name.equals(f.getName())) {
|
||
return f;
|
||
}
|
||
}
|
||
throw new AssertionError("表单里没有字段 " + name + ",实际:" + resp.getForm().getFields());
|
||
}
|
||
|
||
/* ==================== 学生:请假申请 ==================== */
|
||
|
||
@Test
|
||
void studentSayingWantLeaveGetsApplicationFormWithoutCallingLlm() {
|
||
ChatResponse resp = chat(EXPLODING_LLM, "我要请假", caller(AgentRole.STUDENT));
|
||
|
||
assertNotNull(resp.getForm(), "应直接下发表单");
|
||
assertEquals("leave.submit", resp.getForm().getTool(), "学生走「提交请假申请」");
|
||
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
|
||
assertEquals("/my", resp.getForm().getLandingPath());
|
||
assertTrue(resp.getReply().contains("请假申请"), "引导语应说明这是请假申请,实际:" + resp.getReply());
|
||
|
||
// 学生表单:类型 / 起止时间 / 事由;不应出现「学生」这种教师侧字段
|
||
assertNotNull(field(resp, "leaveType"));
|
||
assertNotNull(field(resp, "startDate"));
|
||
assertNotNull(field(resp, "endDate"));
|
||
assertNotNull(field(resp, "remarks"));
|
||
for (FormField f : resp.getForm().getFields()) {
|
||
assertFalse("stuId".equals(f.getName()), "学生自查请假不该出现「学生」字段");
|
||
}
|
||
}
|
||
|
||
/* ==================== 教师:帮学生登记 ==================== */
|
||
|
||
@Test
|
||
void teacherSayingWantLeaveGetsRegisterFormWithClassAndStudentPickers() {
|
||
ChatResponse resp = chat(EXPLODING_LLM, "我要请假", caller(AgentRole.TEACHER));
|
||
|
||
assertNotNull(resp.getForm(), "应直接下发表单");
|
||
assertEquals("leave.register", resp.getForm().getTool(), "教师走「帮学生登记请假」");
|
||
assertEquals("/register", resp.getForm().getLandingPath());
|
||
assertTrue(resp.getReply().contains("登记"), "引导语应说明这是登记,实际:" + resp.getReply());
|
||
|
||
// 教师表单必须能选到人:班级 + 学生 都是下拉,且学生依赖班级
|
||
FormField classId = field(resp, "classId");
|
||
assertEquals("select", classId.getWidget());
|
||
assertNotNull(classId.getOptionCall(), "班级下拉应带上「代取地址」");
|
||
assertEquals("/homeleave/api/leave/MyClasses", classId.getOptionCall().getPathAndQuery());
|
||
|
||
FormField stuId = field(resp, "stuId");
|
||
assertEquals("select", stuId.getWidget());
|
||
assertEquals(Collections.singletonList("classId"), stuId.getDependsOn());
|
||
assertNotNull(stuId.getOptionCall(), "学生下拉应带上「代取地址」");
|
||
assertEquals("/homeleave/api/leave/ClassStudents?classId={{classId}}",
|
||
stuId.getOptionCall().getPathAndQuery(),
|
||
"学生名单的地址要留 {{classId}} 占位符,前端选完班级再替换");
|
||
assertEquals("stuId", stuId.getValueKey());
|
||
assertEquals("stuName", stuId.getLabelKey());
|
||
|
||
assertNotNull(field(resp, "leaveType"));
|
||
assertNotNull(field(resp, "startDate"));
|
||
assertNotNull(field(resp, "endDate"));
|
||
assertNotNull(field(resp, "remarks"));
|
||
}
|
||
|
||
/* ==================== 教师:发布校园公告 ==================== */
|
||
|
||
@Test
|
||
void teacherSayingPublishNoticeGetsPublishFormWithoutCallingLlm() {
|
||
ChatResponse resp = chat(EXPLODING_LLM, "我要发布校园公告", caller(AgentRole.TEACHER));
|
||
|
||
assertNotNull(resp.getForm(), "应直接下发表单");
|
||
assertEquals("notice.publish", resp.getForm().getTool());
|
||
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
|
||
assertEquals("/list/new", resp.getForm().getLandingPath());
|
||
assertTrue(resp.getReply().contains("发布"), "引导语应说明这是发布,实际:" + resp.getReply());
|
||
|
||
// 公告表单:标题(单行)+ 正文(富文本,与模块页 NewNotice.vue 的 wangEditor 一致)+ 发文部门(下拉)
|
||
assertEquals("text", field(resp, "title").getWidget());
|
||
FormField content = field(resp, "content");
|
||
assertEquals("richtext", content.getWidget(), "正文用富文本编辑器(模块页 wangEditor)");
|
||
assertEquals("/campusnotice/api/file/UploadImage", content.getRichTextUploadPath(),
|
||
"正文插图要走模块真实前缀 /campusnotice(且不是附件的 FileUpload)");
|
||
assertEquals("/campusnotice/api/file/Preview?id={id}", content.getRichTextPreviewPath(),
|
||
"图片预览地址模板同样要带前缀,{id} 留给前端替换");
|
||
|
||
FormField deptId = field(resp, "deptId");
|
||
assertEquals("select", deptId.getWidget(), "发文部门应是下拉,不该让用户手填部门编号");
|
||
assertNotNull(deptId.getOptionCall(), "发文部门下拉应带上「代取地址」");
|
||
assertEquals("/campusnotice/api/dict/List?type=1", deptId.getOptionCall().getPathAndQuery(),
|
||
"字典地址必须带 type=1,否则取不回发文部门");
|
||
assertEquals("k", deptId.getValueKey());
|
||
assertEquals("v", deptId.getLabelKey());
|
||
}
|
||
|
||
@Test
|
||
void studentSayingPublishNoticeFallsThroughToModel() {
|
||
// 学生没有发布公告能力 → 不直达,交给模型如实说明需要教师/管理员身份
|
||
ChatResponse resp = chat(ECHO_LLM, "我要发布校园公告", caller(AgentRole.STUDENT));
|
||
assertNull(resp.getForm(), "学生不该拿到发布公告表单");
|
||
assertEquals("(模型回复)", resp.getReply());
|
||
}
|
||
|
||
/* ==================== 教师:发布通知(通知公告模块) ==================== */
|
||
|
||
/**
|
||
* 「通知公告」(key={@code notice})与「校园公告」({@code campusnotice})是**两个独立应用**:
|
||
* 前者按「用户」下发、后者按「班级」下发。同一句「我要发布通知公告」必须落到前者的发布表单,
|
||
* 且表单上**不能**出现只有校园公告才有的字段(推送范围 / 回执 / 发布方式)。
|
||
*/
|
||
@Test
|
||
void teacherSayingPublishNotifyGetsPublishFormWithoutCallingLlm() {
|
||
ChatResponse resp = chat(EXPLODING_LLM, "我要发布通知公告", caller(AgentRole.TEACHER));
|
||
|
||
assertNotNull(resp.getForm(), "应直接下发表单");
|
||
assertEquals("notify.publish", resp.getForm().getTool());
|
||
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
|
||
assertEquals("/list/new", resp.getForm().getLandingPath());
|
||
assertTrue(resp.getReply().contains("发布通知"), "引导语应说明这是发布通知,实际:" + resp.getReply());
|
||
|
||
// 标题(单行)+ 正文(富文本,与模块页的 wangEditor 一致)
|
||
assertEquals("text", field(resp, "title").getWidget());
|
||
FormField content = field(resp, "content");
|
||
assertEquals("richtext", content.getWidget(), "正文用富文本编辑器(模块页 wangEditor)");
|
||
assertEquals("/notice/api/file/UploadImage", content.getRichTextUploadPath(),
|
||
"正文里插图片要打模块真实前缀 /notice(且不是附件的 FileUpload)");
|
||
assertEquals("/notice/api/file/Preview?id={id}", content.getRichTextPreviewPath(),
|
||
"图片预览地址模板同样要带前缀,{id} 留给前端替换");
|
||
|
||
// 来源部门:下拉 + 代取地址(带上模块前缀与固定字典种类)
|
||
FormField deptId = field(resp, "deptId");
|
||
assertEquals("select", deptId.getWidget(), "来源部门应是下拉,不该让用户手填部门编号");
|
||
assertNotNull(deptId.getOptionCall(), "来源部门下拉应带上「代取地址」");
|
||
assertEquals("/notice/api/dict/List?type=1", deptId.getOptionCall().getPathAndQuery(),
|
||
"字典地址必须带模块前缀 /notice 与 type=1,否则取不回来源部门");
|
||
assertEquals("k", deptId.getValueKey());
|
||
assertEquals("v", deptId.getLabelKey());
|
||
|
||
// 通知对象:模块页同款自绘控件(radio 全部用户/指定用户 + 选人弹窗)。
|
||
// ★ 它也有 optionCall,必须确认**没有被降级成 select** ——
|
||
// AgentOrchestrator 对「带 optionCall 的字段」会把控件改写成 select(防止取回字典后还是输入框),
|
||
// userpicker 靠白名单豁免;漏了就变成普通下拉,正是这次要避免的。
|
||
FormField receiveList = field(resp, "receiveList");
|
||
assertEquals("userpicker", receiveList.getWidget(),
|
||
"通知对象应是自绘的「全部用户 / 指定用户」+ 选人弹窗,不能被降级成 select");
|
||
assertNotNull(receiveList.getOptionCall(), "通知对象应带上组织树代取地址(弹窗里用它取原始树)");
|
||
assertEquals("/notice/api/base/GetSchoolAll", receiveList.getOptionCall().getPathAndQuery());
|
||
assertEquals("all", receiveList.getAllToken(), "「全部用户」哨兵值");
|
||
|
||
// ★ 选人弹窗有两个页签(模块页 UserCheckInput.vue 的 sources)——这里钉住**拼好前缀**的结果。
|
||
// 与附件上传同一个坑:只写相对路径、前缀由 resolveModulePath 拼,漏了就线上 404。
|
||
// 注意「自定义组」的路径里带 query,拼前缀时不能被吃掉。
|
||
assertNotNull(receiveList.getPickerSources(), "选人弹窗的数据源页签要下发到前端");
|
||
assertEquals(2, receiveList.getPickerSources().size());
|
||
FormField.PickerSource tab0 = receiveList.getPickerSources().get(0);
|
||
assertEquals("基础平台", tab0.getName());
|
||
assertNotNull(tab0.getCall());
|
||
assertEquals("GET", tab0.getCall().getMethod(), "取组织树是只读 GET");
|
||
assertEquals("/notice/api/base/GetSchoolAll", tab0.getCall().getPathAndQuery(),
|
||
"代取地址必须带模块前缀 /notice");
|
||
FormField.PickerSource tab1 = receiveList.getPickerSources().get(1);
|
||
assertEquals("自定义组", tab1.getName());
|
||
assertEquals("/notice/api/contacts/GetGroupTree?type=1", tab1.getCall().getPathAndQuery(),
|
||
"自定义组树同样要带前缀,且 type=1 要保留");
|
||
|
||
// 校园公告独有的字段绝不能串到通知公告的表单上
|
||
for (String foreign : new String[]{"pushScopes", "publishType", "receiptFlag", "topmost", "type"}) {
|
||
for (FormField f : resp.getForm().getFields()) {
|
||
assertFalse(foreign.equals(f.getName()),
|
||
"通知公告表单不该出现校园公告字段:" + foreign);
|
||
}
|
||
}
|
||
}
|
||
|
||
@Test
|
||
void studentSayingPublishNotifyFallsThroughToModel() {
|
||
ChatResponse resp = chat(ECHO_LLM, "我要发布通知公告", caller(AgentRole.STUDENT));
|
||
assertNull(resp.getForm(), "学生不该拿到发布通知表单");
|
||
assertEquals("(模型回复)", resp.getReply());
|
||
}
|
||
|
||
/* ==================== 教师:发布宿舍通知 ==================== */
|
||
|
||
@Test
|
||
void teacherSayingPublishDormNoticeGetsPublishFormWithoutCallingLlm() {
|
||
ChatResponse resp = chat(EXPLODING_LLM, "我要发布宿舍通知", caller(AgentRole.TEACHER));
|
||
|
||
assertNotNull(resp.getForm(), "应直接下发表单");
|
||
assertEquals("dorm.publish", resp.getForm().getTool());
|
||
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
|
||
assertEquals("/list/new", resp.getForm().getLandingPath());
|
||
|
||
// 标题 + 正文(富文本,模块页 NewNotice.vue 用 wangEditor)+ 通知类型(下拉)+ 发文部门(下拉)
|
||
assertEquals("text", field(resp, "title").getWidget());
|
||
FormField content = field(resp, "content");
|
||
assertEquals("richtext", content.getWidget(), "正文用富文本编辑器(模块页 wangEditor)");
|
||
assertEquals("/dormnotice/api/file/UploadImage", content.getRichTextUploadPath(),
|
||
"正文插图走模块真实前缀 /dormnotice");
|
||
assertEquals("/dormnotice/api/file/Preview?id={id}", content.getRichTextPreviewPath());
|
||
|
||
FormField noticeType = field(resp, "noticeType");
|
||
assertEquals("select", noticeType.getWidget(), "通知类型应是下拉,不该让用户手填编号");
|
||
assertNotNull(noticeType.getOptionCall());
|
||
assertEquals("/dormnotice/api/dict/List?type=noticeType",
|
||
noticeType.getOptionCall().getPathAndQuery(), "字典地址必须带 type=noticeType");
|
||
|
||
FormField deptId = field(resp, "deptId");
|
||
assertEquals("select", deptId.getWidget());
|
||
assertNotNull(deptId.getOptionCall());
|
||
assertEquals("/dormnotice/api/dict/List?type=dept", deptId.getOptionCall().getPathAndQuery());
|
||
assertEquals("k", deptId.getValueKey());
|
||
assertEquals("v", deptId.getLabelKey());
|
||
}
|
||
|
||
@Test
|
||
void studentSayingPublishDormNoticeFallsThroughToModel() {
|
||
ChatResponse resp = chat(ECHO_LLM, "我要发布宿舍通知", caller(AgentRole.STUDENT));
|
||
assertNull(resp.getForm(), "学生不该拿到发布宿舍通知表单");
|
||
assertEquals("(模型回复)", resp.getReply());
|
||
}
|
||
|
||
/* ==================== 教师:发布家校活动 ==================== */
|
||
|
||
@Test
|
||
void teacherSayingPublishActivityGetsPublishFormWithoutCallingLlm() {
|
||
ChatResponse resp = chat(EXPLODING_LLM, "我要发布活动", caller(AgentRole.TEACHER));
|
||
|
||
assertNotNull(resp.getForm(), "应直接下发表单");
|
||
assertEquals("activity.publish", resp.getForm().getTool());
|
||
assertTrue(resp.getForm().isWrite());
|
||
assertEquals("/list/new", resp.getForm().getLandingPath());
|
||
|
||
// 活动表单:标题 + 说明(**富文本**,模块页 PublishActivity.vue 用 wangEditor)+
|
||
// 分类(下拉)+ 主办部门(下拉)+ 地点 + 起止时间
|
||
assertEquals("text", field(resp, "title").getWidget());
|
||
FormField content = field(resp, "content");
|
||
assertEquals("richtext", content.getWidget(), "活动说明用富文本编辑器(模块页 wangEditor)");
|
||
assertEquals("/familyactivity/api/file/UploadImage", content.getRichTextUploadPath(),
|
||
"正文插图走模块真实前缀 /familyactivity(不是附件的 FileUpload)");
|
||
assertEquals("/familyactivity/api/file/Preview?id={id}", content.getRichTextPreviewPath());
|
||
|
||
FormField category = field(resp, "category");
|
||
assertEquals("select", category.getWidget());
|
||
assertNotNull(category.getOptionCall());
|
||
assertEquals("/familyactivity/api/dict/List?type=2", category.getOptionCall().getPathAndQuery());
|
||
|
||
FormField deptId = field(resp, "deptId");
|
||
assertEquals("select", deptId.getWidget());
|
||
assertNotNull(deptId.getOptionCall());
|
||
assertEquals("/familyactivity/api/dict/List?type=1", deptId.getOptionCall().getPathAndQuery());
|
||
|
||
assertNotNull(field(resp, "venue"));
|
||
assertNotNull(field(resp, "startTime"));
|
||
assertNotNull(field(resp, "endTime"));
|
||
}
|
||
|
||
@Test
|
||
void studentSayingPublishActivityFallsThroughToModel() {
|
||
ChatResponse resp = chat(ECHO_LLM, "我要发布活动", caller(AgentRole.STUDENT));
|
||
assertNull(resp.getForm(), "学生不该拿到发布活动表单");
|
||
assertEquals("(模型回复)", resp.getReply());
|
||
}
|
||
|
||
/* ==================== 教师:发布专业发展记录 ==================== */
|
||
|
||
/**
|
||
* 「我要发布专业发展」必须**直接**弹表单,而不是先被模型反问「你要发到哪个栏目」。
|
||
*
|
||
* <p>用户反馈的原话:说「我要发布专业发展」被问了一轮栏目,答完才出表单,
|
||
* 表单里却还留着空的「所属栏目」要再选一次 —— 同一件事问了两遍。
|
||
* 栏目是学校自建的(各校 id 不同),所以直达时不预置,直接交给表单里的下拉选。
|
||
*/
|
||
@Test
|
||
void teacherSayingPublishProfessionRecordGetsPublishFormWithoutCallingLlm() {
|
||
ChatResponse resp = chat(EXPLODING_LLM, "我要发布专业发展", caller(AgentRole.TEACHER));
|
||
|
||
assertNotNull(resp.getForm(), "应直接下发表单");
|
||
assertEquals("profession.publish", resp.getForm().getTool());
|
||
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
|
||
assertEquals("/record/new", resp.getForm().getLandingPath());
|
||
assertTrue(resp.getReply().contains("所属栏目"),
|
||
"引导语要点明表单里就能选栏目,实际:" + resp.getReply());
|
||
|
||
// 「所属栏目」是必填下拉,且带着「用用户自己的登录态去取栏目树」的地址
|
||
FormField moduleId = field(resp, "moduleId");
|
||
assertEquals("select", moduleId.getWidget(), "栏目应是下拉,不该让用户手填栏目编号");
|
||
assertTrue(moduleId.isRequired(), "栏目必填");
|
||
assertNotNull(moduleId.getOptionCall(), "栏目下拉应带上「代取地址」");
|
||
assertEquals("/profession/api/moudule/ModuleTree", moduleId.getOptionCall().getPathAndQuery());
|
||
|
||
// 栏目**不能**被直达预置:各校栏目 id 不同,硬编码必然出错
|
||
assertFalse(resp.getForm().getPreset().containsKey("moduleId"),
|
||
"直达不应预置栏目,实际 preset:" + resp.getForm().getPreset());
|
||
|
||
// 发布页是全字段表单(与模块页 RecordEdit.vue 对齐)
|
||
assertTrue(resp.getForm().isAllFields(), "发布表单应渲染全部字段");
|
||
assertNotNull(field(resp, "title"));
|
||
assertNotNull(field(resp, "remark"));
|
||
assertNotNull(field(resp, "fileIds"));
|
||
}
|
||
|
||
@Test
|
||
void studentSayingPublishProfessionRecordFallsThroughToModel() {
|
||
ChatResponse resp = chat(ECHO_LLM, "我要发布专业发展", caller(AgentRole.STUDENT));
|
||
assertNull(resp.getForm(), "学生不该拿到发布专业发展表单");
|
||
assertEquals("(模型回复)", resp.getReply());
|
||
}
|
||
|
||
/* ==================== 不该被劫持的情况 ==================== */
|
||
|
||
@Test
|
||
void detailedSentenceStillGoesToTheModel() {
|
||
// 带时间与事由的句子由模型处理(它能一次把参数填全),不能被直达规则截走
|
||
ChatResponse resp = chat(ECHO_LLM, "我要请10月12日上午的病假,发烧就医", caller(AgentRole.STUDENT));
|
||
assertNull(resp.getForm(), "含细节的句子不应被直达成空表单");
|
||
assertEquals("(模型回复)", resp.getReply());
|
||
}
|
||
|
||
@Test
|
||
void guestSayingWantLeaveFallsThroughToModel() {
|
||
// 访客无权使用请假工具 → 不直达,交给模型如实说明需要什么身份
|
||
ChatResponse resp = chat(ECHO_LLM, "我要请假", caller(AgentRole.GUEST));
|
||
assertNull(resp.getForm(), "访客不该拿到请假表单");
|
||
assertEquals("(模型回复)", resp.getReply());
|
||
}
|
||
|
||
@Test
|
||
void unrelatedMessageStillGoesToTheModel() {
|
||
ChatResponse resp = chat(ECHO_LLM, "这周有什么新的校园公告?", caller(AgentRole.STUDENT));
|
||
assertNull(resp.getForm());
|
||
assertEquals("(模型回复)", resp.getReply());
|
||
}
|
||
}
|