feat(表单): 发布表单支持「条件显示」,宿舍通知/家校活动按模块页口径联动
build / build-push (push) Successful in 53s

- ToolParam.visibleWhen(依赖字段, 取值…) + FormField.visibleWhen:字段随上级字段取值增删;
  前端 FormCard.isFieldVisible() 统一管渲染 / 必填校验 / 提交体过滤(只判断显示,不清值)。
- ToolParam.requiredWhenVisible():条件必填,只约束界面这一次填写,不进给模型的 schema
  —— 否则模型会在「不需要」的场景硬凑一个值出来。
- ToolParam.hiddenValue(值):隐藏时提交体里仍带固定值。两种隐藏口径必须区分:
  · 宿舍通知「回执单据」= `: undefined` ⇒ 隐藏即整键不发,不声明 hiddenValue;
  · 家校活动「名额上限/家长随行」= `? 0 : …` ⇒ 控件隐藏但照发 0,声明 hiddenValue(0)。
  没配 visibleWhen 却给了 hiddenValue 直接抛异常(恒显示的字段上它是死配置)。
- dorm.publish:formType 仅 receiptMode=fill 显示且条件必填;needSupervisor 在 confirm|fill 显示。
- activity.publish:quota / allowAccompany 在 signup|confirm 显示,隐藏时提交 0;说明词同步。
- 契约测试 +2:活动联动(含「与宿舍口径必须相反」的反向断言)、
  hiddenValue 必须搭配 visibleWhen 且取值须是字段自己的合法选项。
This commit is contained in:
lijing
2026-10-15 15:47:28 +08:00
parent 006003c842
commit 50f6fa8f75
5 changed files with 310 additions and 6 deletions
@@ -149,6 +149,42 @@ public class FormField {
/** 文本最大长度(前端显示字数统计) */ /** 文本最大长度(前端显示字数统计) */
private Integer maxLength; private Integer maxLength;
/**
* 条件显示:本字段**仅当**所列字段的当前取值命中给定值时渲染、校验、提交。
*
* <p>形如 {@code {receiptMode: ["confirm","fill"]}} = 「宿管到场配合」只在需要回执时出现;
* 多个键之间是「且」的关系;为空 = 恒显示(老字段全都是这个形态,行为不变)。
*
* <p>★ 为什么要这条能力:宿舍通知模块页「新建通知」里,**回执方式**这个单选决定了下面
* 还显示哪些参数({@code NewNotice.vue} 的 {@code showReceiptOpts / isFill} 两个 computed):
* 「仅通知」时既没有「回执单据」,也没有「宿管到场配合」。助手的表单若把字段一次性全摆出来,
* 用户会看到两个当前场景根本不存在的参数,还得自己判断该不该填。
*/
private Map<String, List<String>> visibleWhen;
/**
* 条件必填:只有当 {@link #visibleWhen} 成立(字段可见)时才要求必填。
*
* <p>★ 为什么不能直接用 {@link #required}:{@code required} 会被写进**给模型的 JSON Schema**
* 的 {@code required} 数组里,于是模型在「仅通知」场景也会硬凑一个「回执单据」出来。
* 条件必填只约束**界面上这一次填写**(如「回执单据」在「需填报」时必选),不该进模型 schema。
*/
private boolean requiredWhenVisible;
/**
* 「隐藏时提交固定值」:本字段因 {@link #visibleWhen} 不成立而不显示时,提交体里照样带上这个值。
*
* <p>对齐模块页**两种隐藏口径**中的后者(前者不用本字段):
* <ul>
* <li><b>不提交</b>:宿舍通知 {@code NewNotice.vue} 的
* {@code form.receiptMode === "fill" ? form.formType : undefined} ⇒ {@code hiddenValue} 为 null。</li>
* <li><b>提交固定值</b>:家校活动 {@code NewActivity.vue} 的
* {@code quota: form.receiptMode === "none" ? 0 : ...} ⇒ {@code hiddenValue = 0},
* 即「仅通知」时控件不渲染,但请求体里照旧带 {@code quota: 0} / {@code allowAccompany: 0}。</li>
* </ul>
*/
private Object hiddenValue;
private boolean required; private boolean required;
/** 说明(来自工具定义,写清格式与含义) */ /** 说明(来自工具定义,写清格式与含义) */
@@ -233,6 +233,40 @@ public class ToolParam {
/** 文本最大长度(对齐模块页 {@code maxlength} + 字数统计) */ /** 文本最大长度(对齐模块页 {@code maxlength} + 字数统计) */
private Integer maxLength; private Integer maxLength;
/**
* 条件显示:本字段仅当所列字段取到给定值时渲染。
*
* <p>形如 {@code visibleWhen("receiptMode", "confirm", "fill")} —— 宿舍通知模块页的
* 「宿管到场配合」只在「回执方式 ≠ 仅通知」时出现。多个键之间是「且」。
*/
private Map<String, List<String>> visibleWhen;
/**
* 条件必填:仅当 {@link #visibleWhen} 成立(界面上真的显示了本字段)时才要求必填。
*
* <p>★ 与 {@link #required} 的区别:{@code required} 会进**给模型的 schema**,
* 而条件必填只是界面这一次填写的约束(宿舍通知的「回执单据」在「需填报」时必选,
* 但「仅通知」时模型不该被要求给这个值)。
*/
private boolean requiredWhenVisible;
/**
* 「隐藏时提交固定值」:本字段因 {@link #visibleWhen} 不成立而不显示时,提交体里照样带上这个值。
*
* <p>★ 模块页的隐藏有**两种口径**,必须区分,不能一律「不提交」:
* <ul>
* <li><b>不提交</b>(宿舍通知「回执单据」):{@code NewNotice.vue} 写的是
* {@code form.receiptMode === "fill" ? form.formType : undefined}
* ⇒ 不该给 {@code hiddenValue},隐藏时整键不发。</li>
* <li><b>提交固定值</b>(家校活动「名额上限 / 家长随行」):{@code NewActivity.vue} 写的是
* {@code quota: form.receiptMode === "none" ? 0 : Number(form.quota) || 0}
* ⇒ 控件在「仅通知」时不渲染,但请求体里照样带 {@code quota: 0}、
* {@code allowAccompany: 0},用 {@code hiddenValue=0} 表达。</li>
* </ul>
* 一律「不提交」会让「仅通知」的活动在库里留着上次的名额 / 随行值,与模块页提交体不一致。
*/
private Object hiddenValue;
public ToolParam() { public ToolParam() {
} }
@@ -530,6 +564,59 @@ public class ToolParam {
return this; return this;
} }
/**
* 条件显示:本字段只在 {@code field} 取到所列**任一**值时渲染、校验与提交。
*
* <p>★ 对齐模块页的 {@code v-if} 联动。宿舍通知 {@code NewNotice.vue}:
* <pre>
* const isFill = computed(() =&gt; form.receiptMode === "fill"); // 回执单据
* const showReceiptOpts = computed(() =&gt; form.receiptMode !== "none"); // 宿管到场配合
* </pre>
* ⇒ {@code .visibleWhen("receiptMode", "fill")} 与
* {@code .visibleWhen("receiptMode", "confirm", "fill")}。
*
* <p>不声明 = 恒显示(既有字段的行为完全不变)。可多次调用声明多个条件(之间是「且」)。
*
* @param field 决定可见性的那个字段名(必须是**同一工具**的另一个参数)
* @param values 命中其中任一值即显示(按字符串比较,如 {@code "fill"})
*/
public ToolParam visibleWhen(String field, String... values) {
if (StringUtils.isBlank(field) || values == null || values.length == 0) {
throw new IllegalArgumentException("visibleWhen 需要「依赖字段名 + 至少一个取值」");
}
if (this.visibleWhen == null) {
this.visibleWhen = new LinkedHashMap<>();
}
this.visibleWhen.put(field, Arrays.asList(values));
return this;
}
/**
* 条件必填:仅当本字段因 {@link #visibleWhen} 真的显示在界面上时才要求填写。
*
* <p>模块页「需填报」类通知必须选定「回执单据」,但「仅通知」时压根没有这个控件 ⇒
* 用 {@code .required} 会让模型在「仅通知」场景也硬凑一个值(required 会进模型 schema)。
*/
public ToolParam requiredWhenVisible() {
this.requiredWhenVisible = true;
return this;
}
/**
* 隐藏时提交固定值:本字段因 {@link #visibleWhen} 不成立而**不显示**时,提交体里带上 {@code value}。
*
* <p>★ 只对「模块页隐藏了仍然提交」的字段使用(家校活动 {@code NewActivity.vue}:
* {@code quota: form.receiptMode === "none" ? 0 : ...})。宿舍通知这类
* 「隐藏即整键不发」的字段**不要**调用它。
*
* <p>与 {@link #dft} 的区别:{@code dft} 是用户**看得见**时输入框里的初始值,
* 本方法是字段**看不见**时提交体里的占位值,两者互不影响。
*/
public ToolParam hiddenValue(Object value) {
this.hiddenValue = value;
return this;
}
/** /**
* 覆盖附件上传地址。★ 传**模块内相对路径**(如 {@code /api/file/Upload}), * 覆盖附件上传地址。★ 传**模块内相对路径**(如 {@code /api/file/Upload}),
* 模块前缀由编排层统一拼;不传则用模块默认的 {@code /api/base/FileUpload}。 * 模块前缀由编排层统一拼;不传则用模块默认的 {@code /api/base/FileUpload}。
@@ -673,6 +760,16 @@ public class ToolParam {
f.setSwitchOff(switchOff); f.setSwitchOff(switchOff);
f.setRows(rows); f.setRows(rows);
f.setMaxLength(maxLength); f.setMaxLength(maxLength);
// 条件显示 / 条件必填(对齐模块页「回执方式」决定的联动)
f.setVisibleWhen(visibleWhen);
f.setRequiredWhenVisible(requiredWhenVisible);
// 隐藏时提交体里仍带的固定值(对齐模块页「控件隐藏但请求体照发」的那种写法)
// ★ 没声明 visibleWhen 的字段恒显示,固定值永远用不上 ⇒ 直接报错,别写成静默死配置
if (hiddenValue != null && (visibleWhen == null || visibleWhen.isEmpty())) {
throw new IllegalStateException(
"参数 " + name + " 声明了 hiddenValue 却没有 visibleWhen —— 字段恒显示,该固定值永远用不上");
}
f.setHiddenValue(hiddenValue);
// 富文本正文的图片上传/预览地址(相对路径,前缀在编排层拼) // 富文本正文的图片上传/预览地址(相对路径,前缀在编排层拼)
f.setRichTextUploadPath(richTextUploadOverride); f.setRichTextUploadPath(richTextUploadOverride);
f.setRichTextPreviewPath(richTextPreviewOverride); f.setRichTextPreviewPath(richTextPreviewOverride);
@@ -107,6 +107,8 @@ public final class ActivityTools {
"发布一条家校活动(可要求家长报名)。字段与家校活动模块的「新建活动」页**完全一致**:" "发布一条家校活动(可要求家长报名)。字段与家校活动模块的「新建活动」页**完全一致**:"
+ "标题、活动分类、主办部门、活动地点、活动时间、报名截止、回执方式、名额上限、" + "标题、活动分类、主办部门、活动地点、活动时间、报名截止、回执方式、名额上限、"
+ "家长随行、活动费用、接收班级、短信提醒、附件、正文、定时发送、存草稿。" + "家长随行、活动费用、接收班级、短信提醒、附件、正文、定时发送、存草稿。"
+ "其中「名额上限 / 家长随行」只在回执方式为「需报名 / 需确认」时才出现"
+ "(「仅通知」的表单里不显示这两项,系统按模块页口径提交 0),不要追问。"
+ "调用前必须确认:标题 title、正文 content、活动分类 category、主办部门 deptId、" + "调用前必须确认:标题 title、正文 content、活动分类 category、主办部门 deptId、"
+ "活动地点 venue、开始/结束时间 startTime/endTime;" + "活动地点 venue、开始/结束时间 startTime/endTime;"
+ "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;" + "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;"
@@ -144,10 +146,16 @@ public final class ActivityTools {
.options(new String[]{"none", "signup", "confirm"}, .options(new String[]{"none", "signup", "confirm"},
new String[]{"仅通知", "需报名", "需确认"}), new String[]{"仅通知", "需报名", "需确认"}),
ToolParam.optional("quota", "integer", "名额上限(0 或留空 = 不限)") ToolParam.optional("quota", "integer", "名额上限(0 或留空 = 不限)")
.label("名额上限").dft("0"), .label("名额上限").dft("0")
// ★ 模块页 NewActivity.vue:`showReceiptOpts = receiptMode !== "none"`
// 包住「名额上限 / 家长随行」两项 —— 「仅通知」时这两个控件不渲染。
// 但**提交体里照旧带 0**(`quota: receiptMode === "none" ? 0 : ...`),
// 所以这里要配 hiddenValue(0),不能像宿舍通知那样整键不发。
.visibleWhen("receiptMode", "signup", "confirm").hiddenValue(0),
ToolParam.optional("allowAccompany", "integer", "是否允许家长随行") ToolParam.optional("allowAccompany", "integer", "是否允许家长随行")
.label("家长随行").dft("1") .label("家长随行").dft("1")
.options(new String[]{"1", "0"}, new String[]{"允许", "不允许"}), .options(new String[]{"1", "0"}, new String[]{"允许", "不允许"})
.visibleWhen("receiptMode", "signup", "confirm").hiddenValue(0),
ToolParam.optional("cost", "number", "活动费用(元,留空=免费)") ToolParam.optional("cost", "number", "活动费用(元,留空=免费)")
.label("活动费用"), .label("活动费用"),
ToolParam.optional("receiveList", "array", "接收班级:班级 id 列表;留空或含 ALL = 全部班级") ToolParam.optional("receiveList", "array", "接收班级:班级 id 列表;留空或含 ALL = 全部班级")
@@ -117,6 +117,9 @@ public final class DormNoticeTools {
+ "字段与宿舍通知模块的「新建通知」页**完全一致**:" + "字段与宿舍通知模块的「新建通知」页**完全一致**:"
+ "标题、通知类型、发布部门、紧急程度、生效时间、回执截止、回执方式、回执单据、" + "标题、通知类型、发布部门、紧急程度、生效时间、回执截止、回执方式、回执单据、"
+ "接收宿舍、宿管配合、置顶、短信、附件、正文、定时发送、存草稿。" + "接收宿舍、宿管配合、置顶、短信、附件、正文、定时发送、存草稿。"
+ "★「回执方式」决定后面还有哪些参数(模块页就是这么联动的):"
+ "仅通知 = 既不要「回执单据」也不要「宿管到场配合」;"
+ "需确认 = 只多一个「宿管到场配合」;需填报 = 两者都要,且「回执单据」必选。"
+ "调用前必须确认:标题 title、正文 content、通知类型 noticeType、发文部门 deptId;" + "调用前必须确认:标题 title、正文 content、通知类型 noticeType、发文部门 deptId;"
+ "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;" + "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;"
+ "通知类型与发文部门必须来自字典,不要编造。" + "通知类型与发文部门必须来自字典,不要编造。"
@@ -151,9 +154,12 @@ public final class DormNoticeTools {
.label("回执方式").dft("none") .label("回执方式").dft("none")
.options(new String[]{"none", "confirm", "fill"}, .options(new String[]{"none", "confirm", "fill"},
new String[]{"仅通知", "需确认", "需填报"}), new String[]{"仅通知", "需确认", "需填报"}),
ToolParam.optional("formType", "string", "回执单据类型(receiptMode=fill 时必填),来自 dorm.formTemplates 的 type 字段") ToolParam.optional("formType", "string", "回执单据类型(仅「回执方式=需填报」时需要),来自 dorm.formTemplates 的 type 字段")
.label("回执单据") .label("回执单据")
.optionsFrom("dorm.formTemplates").optionsKeys("type", "name"), .optionsFrom("dorm.formTemplates").optionsKeys("type", "name")
// ★ 模块页 NewNotice.vue:`isFill = receiptMode === "fill"` 时才渲染
// 「回执单据」这一项(选单据类型 + 家长端字段预览),且「需填报」必须选定单据。
.visibleWhen("receiptMode", "fill").requiredWhenVisible(),
// ★ 接收宿舍 = 模块页那个自写的「选择接收宿舍」弹窗同款控件 // ★ 接收宿舍 = 模块页那个自写的「选择接收宿舍」弹窗同款控件
// (左楼栋列表 / 右按楼层分组的房间勾选 / 底部已选间数与住宿生人数)。 // (左楼栋列表 / 右按楼层分组的房间勾选 / 底部已选间数与住宿生人数)。
// 提交口径不变:房间 id 逗号串,或全选哨兵 "ALL"。 // 提交口径不变:房间 id 逗号串,或全选哨兵 "ALL"。
@@ -164,9 +170,12 @@ public final class DormNoticeTools {
.pickerSource(PICKER_SOURCE_BUILDING[0], PICKER_SOURCE_BUILDING[1]) .pickerSource(PICKER_SOURCE_BUILDING[0], PICKER_SOURCE_BUILDING[1])
.pickerSource(PICKER_SOURCE_ROOM[0], PICKER_SOURCE_ROOM[1]) .pickerSource(PICKER_SOURCE_ROOM[0], PICKER_SOURCE_ROOM[1])
.join(",").allOption("ALL", "全部宿舍"), .join(",").allOption("ALL", "全部宿舍"),
ToolParam.optional("needSupervisor", "integer", "是否需要宿管到场配合执行(仅需回执时有意义)") ToolParam.optional("needSupervisor", "integer", "是否需要宿管到场配合执行(仅「回执方式」不是「仅通知」时才需要)")
.label("宿管到场配合").dft("0") .label("宿管到场配合").dft("0")
.options(new String[]{"0", "1"}, new String[]{"否", "是"}), .options(new String[]{"0", "1"}, new String[]{"否", "是"})
// ★ 模块页 NewNotice.vue:`showReceiptOpts = receiptMode !== "none"` ——
// 「仅通知」时这一项根本不渲染。
.visibleWhen("receiptMode", "confirm", "fill"),
ToolParam.optional("topmost", "integer", "是否置顶展示") ToolParam.optional("topmost", "integer", "是否置顶展示")
.label("置顶展示").dft("0") .label("置顶展示").dft("0")
.options(new String[]{"0", "1"}, new String[]{"否", "是"}), .options(new String[]{"0", "1"}, new String[]{"否", "是"}),
@@ -121,6 +121,160 @@ class ModuleFormParityTest {
} }
} }
/* ==================== ②b 宿舍通知:字段随「回执方式」联动 ==================== */
/**
* 宿舍通知模块页 {@code NewNotice.vue} 里,「回执方式」决定了下面还显示哪些参数:
*
* <pre>
* const isFill = computed(() =&gt; form.receiptMode === "fill"); // → 「回执单据」
* const showReceiptOpts = computed(() =&gt; form.receiptMode !== "none"); // → 「宿管到场配合」
* </pre>
*
* <p>助手表单原先把这两项一次性全摆出来 —— 「仅通知」档位的用户会看到两个当前场景根本
* 不存在的参数,还得自己判断该不该填。这里把口径钉死,并顺带钉住「条件必填**不能**写成
* {@code required}」:{@code required} 会进给模型的 schema,于是模型在「仅通知」时
* 也会硬凑一个单据类型出来。
*/
@Test
void dormPublishReceiptModeDrivesTheVisibleFieldSet() {
ToolDef def = registry.get("dorm.publish");
ToolParam formType = param(def, "formType");
assertNotNull(formType, "宿舍通知要有「回执单据」");
assertNotNull(formType.getVisibleWhen(), "「回执单据」要声明条件显示");
assertEquals(java.util.Collections.singletonList("fill"), formType.getVisibleWhen().get("receiptMode"),
"「回执单据」只在「回执方式=需填报」时出现(模块页 v-if=\"isFill\")");
assertTrue(formType.isRequiredWhenVisible(),
"「需填报」必须选定单据类型,但这是**条件必填**(界面上这一次填写的约束)");
assertFalse(formType.isRequired(),
"条件必填不能写成 required —— 那会让模型在「仅通知」时也硬凑一个值");
ToolParam needSupervisor = param(def, "needSupervisor");
assertNotNull(needSupervisor, "宿舍通知要有「宿管到场配合」");
assertNotNull(needSupervisor.getVisibleWhen(), "「宿管到场配合」要声明条件显示");
assertEquals(Arrays.asList("confirm", "fill"), needSupervisor.getVisibleWhen().get("receiptMode"),
"「宿管到场配合」在「不是仅通知」时出现(模块页 v-if=\"showReceiptOpts\")");
// 条件必填不进给模型的 required
com.alibaba.fastjson.JSONArray required = def.toOpenAiTool().getJSONObject("function")
.getJSONObject("parameters").getJSONArray("required");
assertFalse(required.contains("formType"), "条件必填不该出现在给模型的 required 里:" + required);
// 下发到前端的表单字段也要带上这两份信息(前端据此渲染 / 校验 / 过滤提交体)
FormField emitted = formType.toFormField();
assertEquals(java.util.Collections.singletonList("fill"), emitted.getVisibleWhen().get("receiptMode"));
assertTrue(emitted.isRequiredWhenVisible());
}
/**
* 通用不变量:凡声明了条件显示的字段,其依赖必须是**同一工具里真实存在**的字段,
* 且所列取值必须是该依赖字段真实的选项。
*
* <p>★ 为什么值得单列一条:条件显示写错是**静默失效** —— 依赖字段名打错一个字
* ({@code receiptMode} 写成 {@code receiptType}),前端永远判「不显示」,
* 字段就此从表单上消失,不报任何错,单模块的字段断言也发现不了。
*/
@Test
void everyConditionalFieldDependsOnARealSiblingWithRealValues() {
int checked = 0;
for (ToolDef def : registry.all()) {
for (ToolParam p : def.getParams()) {
Map<String, List<String>> spec = p.getVisibleWhen();
if (spec == null || spec.isEmpty()) {
continue;
}
checked++;
for (Map.Entry<String, List<String>> e : spec.entrySet()) {
ToolParam dep = param(def, e.getKey());
assertNotNull(dep, def.getName() + " 的字段「" + p.getName()
+ "」依赖了不存在的字段「" + e.getKey() + "」—— 前端会永远判为不显示,字段静默消失");
List<String> known = dep.getEnumValues() != null ? dep.getEnumValues() : dep.getFallbackValues();
assertNotNull(known, def.getName() + " 的联动依赖「" + e.getKey()
+ "」没有固定取值,前端判不出可见性");
for (String v : e.getValue()) {
assertTrue(known.contains(v), def.getName() + " 的字段「" + p.getName()
+ "」声明的取值「" + v + "」不在「" + e.getKey() + "」的选项里:" + known);
}
}
}
}
assertTrue(checked >= 2, "至少应覆盖宿舍通知的两处联动,实际 " + checked);
}
/**
* 家校活动:「名额上限 / 家长随行」也随「回执方式」联动,但隐藏口径与宿舍通知**相反** ——
* 控件不渲染,请求体里照旧带 0。
*
* <p>{@code NewActivity.vue}:
* <pre>
* const showReceiptOpts = computed(() =&gt; form.receiptMode !== "none"); // 包住这两项
* quota: form.receiptMode === "none" ? 0 : Number(form.quota) || 0,
* allowAccompany: form.receiptMode === "none" ? 0 : form.allowAccompany ? 1 : 0,
* </pre>
*
* <p>★ 这条与 {@link #dormPublishReceiptModeDrivesTheVisibleFieldSet()} 是**成对的反例**:
* 宿舍是「隐藏即整键不发」,活动是「隐藏但照发固定值」。要是照搬一种口径,
* 「仅通知」的活动就会在库里留着上次的名额 / 随行值(或反之,多发一个模块页不产出的值)。
*/
@Test
void activityReceiptModeHidesQuotaAndAccompanyButStillSubmitsZero() {
ToolDef def = registry.get("activity.publish");
for (String name : new String[]{"quota", "allowAccompany"}) {
ToolParam p = param(def, name);
assertNotNull(p, "家校活动发布要有 " + name);
assertNotNull(p.getVisibleWhen(), name + " 要声明条件显示");
assertEquals(Arrays.asList("signup", "confirm"), p.getVisibleWhen().get("receiptMode"),
name + " 在「回执方式 ≠ 仅通知」时才出现(模块页 v-if=\"showReceiptOpts\")");
assertNotNull(p.getHiddenValue(), name + " 隐藏时要按模块页口径照旧提交 0,不能整键不发");
assertEquals(0, ((Number) p.getHiddenValue()).intValue(), name + " 的固定值应为 0");
assertFalse(p.isRequired(), name + " 不是必填");
assertFalse(p.isRequiredWhenVisible(), name + " 也不是条件必填(模块页这两项没有校验)");
}
// 下发到前端的字段同样要带齐两份信息(前端据此渲染 + 过滤 / 补齐提交体)
FormField emitted = param(def, "quota").toFormField();
assertEquals(Arrays.asList("signup", "confirm"), emitted.getVisibleWhen().get("receiptMode"));
assertEquals(0, ((Number) emitted.getHiddenValue()).intValue());
// 两种口径必须真的不同:宿舍那两项**没有** hiddenValue
assertNull(param(registry.get("dorm.publish"), "formType").getHiddenValue(),
"宿舍通知的「回执单据」隐藏时整键不发,不该有 hiddenValue(与家校活动的口径相反)");
assertNull(param(registry.get("dorm.publish"), "needSupervisor").getHiddenValue(),
"宿舍通知的「宿管到场配合」隐藏时整键不发,不该有 hiddenValue");
}
/**
* 通用不变量:{@code hiddenValue}(「隐藏时提交固定值」)只在**声明了 visibleWhen** 的字段上成立;
* 且该固定值必须是本字段自己的合法取值(有固定选项时)。
*
* <p>★ 为什么值得单列:hiddenValue 写错是**静默的错误数据** —— 字段永远显示时它就是死配置
* (用户看不到任何异常);写成一个本字段根本不产出的值(如 allowAccompany=9),
* 等于往业务库塞模块页永远不会写的东西,界面上完全看不出来。
*/
@Test
void hiddenValueIsOnlyUsedWithVisibilityAndIsALegalValueOfItsOwnField() {
int checked = 0;
for (ToolDef def : registry.all()) {
for (ToolParam p : def.getParams()) {
Object hv = p.getHiddenValue();
if (hv == null) {
continue;
}
checked++;
assertNotNull(p.getVisibleWhen(), def.getName() + " 的「" + p.getName()
+ "」声明了 hiddenValue 却没有 visibleWhen —— 字段恒显示,这个固定值永远用不上");
List<String> known = p.getEnumValues();
if (known != null) {
assertTrue(known.contains(String.valueOf(hv)), def.getName() + " 的「" + p.getName()
+ "」的 hiddenValue=" + hv + " 不在它自己的选项里:" + known);
}
}
}
assertTrue(checked >= 2, "至少应覆盖家校活动的两处(名额上限 / 家长随行),实际 " + checked);
}
/* ==================== ③ 活动:分类存名称 ==================== */ /* ==================== ③ 活动:分类存名称 ==================== */
@Test @Test