test(unit): 三个纯函数的最小单测(12 条)—— 本仓库第一批单测(F-9)

后端验证一直靠 E2E(打真实接口,很硬但要起服务连库)。这批先给
「输入输出确定、没有 IO」的纯函数补最小单测,成本最低、回归收益最高;
其余继续靠 E2E,不为了覆盖率硬凑。

· IdentityNormalizerTest:姓名去所有空白但**保留间隔号**(少数民族姓名的
  「·」是真实的,顺手清掉等于改人名字)、全角转半角、证件号小写 x 收尾统一、
  null 不抛 NPE
· FileProcessServiceTest:扩展名必须自带点号(少一个字符不报错,只会产出
  「身份证jpg」这种客户双击打不开的文件 —— F-46)、空变量用「未填」占位
  (留空会出现「张--身份证.jpg」)、文件名里的 / 必须清掉(「学籍表(1/2)」
  带 / 根本写不进磁盘)、超长截到 90 且不吃掉扩展名、sanitize 收拢连续分隔符

每条断言都对应一个真实踩过的坑 —— 断言的是「有理由的规则」,不是覆盖率。

验证:mvn test → 12/12 通过(exit 0)
This commit is contained in:
2026-09-30 12:38:48 +08:00
parent 57bf39d5a1
commit f89cd998ef
2 changed files with 139 additions and 0 deletions
@@ -0,0 +1,88 @@
package cn.tianbaotong.fill.module.file.service;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 规范文件名引擎的最小单测(《08》F-9 / F-46)。
*
* <p>断言的是<b>有理由的规则</b>,不是覆盖率:
* <ul>
* <li>扩展名必须自带点号(F-46:少一个字符不报错,只会产出「身份证jpg」这种
* 客户双击打不开的文件);</li>
* <li>空变量用「未填」占位,而不是留空 —— 留空会出现「张同学--身份证.jpg」,
* 看起来像系统出错;</li>
* <li>文件名里的 {@code /} 必须被清掉(学生的「学籍表(1/2)」很常见,
* 带 / 的名字根本写不进磁盘);</li>
* <li>超长截断 —— Windows 路径 260 限制,目录还要留空间。</li>
* </ul>
*/
class FileProcessServiceTest {
private final FileProcessService svc = new FileProcessService();
private static Map<String, String> vars(String name, String id, String material) {
Map<String, String> m = new HashMap<>();
m.put("姓名", name);
m.put("证件号", id);
m.put("材料名", material);
return m;
}
@Test
void 默认模板_姓名证件号材料名_扩展名带点() {
String n = svc.renderName(null, vars("张三", "361029200907295110", "身份证"), ".jpg");
assertEquals("张三-361029200907295110-身份证.jpg", n);
}
@Test
void 模板带后缀占位符时不重复拼扩展名() {
String n = svc.renderName("{材料名}-{姓名}{后缀}",
vars("张三", "361029200907295110", "身份证"), ".jpg");
assertEquals("身份证-张三.jpg", n);
}
@Test
void 空变量用未填占位_不会出现双横线() {
String n = svc.renderName(null, vars("张三", "", "身份证"), ".jpg");
assertEquals("张三-未填-身份证.jpg", n);
assertFalse(n.contains("--"), "留空会出现「张--身份证.jpg」,看起来像系统出错: " + n);
}
@Test
void 文件名里的斜杠必须被清掉() {
// 「学籍表(1/2)」在真实学生的上传里很常见;带 / 的名字写不进磁盘
String n = svc.renderName(null, vars("张三", "361029200907295110", "学籍表(1/2)"), ".jpg");
assertFalse(n.contains("/"), "斜杠会导致写盘失败: " + n);
assertTrue(n.contains("学籍表(1_2)"));
}
@Test
void 超长文件名截到安全长度() {
Map<String, String> m = vars("张".repeat(120), "361029200907295110", "身份证");
String n = svc.renderName(null, m, ".jpg");
assertTrue(n.length() <= 90, "Windows 路径 260 限制,正文要给目录留空间: " + n.length());
assertTrue(n.endsWith(".jpg"), "截断不能吃掉扩展名");
}
@Test
void sanitize_空白给未命名_全非法字符收拢成单个下划线() {
// "///" → "___" → 收拢成 "_"(不是「未命名」:未命名只留给纯空白,
// 全非法字符收拢后仍是可写盘的合法名字)
assertEquals("_", FileProcessService.sanitize("///"));
assertEquals("未命名", FileProcessService.sanitize(" "));
assertEquals(null, FileProcessService.sanitize(null));
}
@Test
void sanitize_连续分隔符收拢() {
assertEquals("a_b", FileProcessService.sanitize("a___b"));
assertEquals("a-b", FileProcessService.sanitize("a---b"));
}
}
@@ -0,0 +1,51 @@
package cn.tianbaotong.fill.support;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* {@link IdentityNormalizer} 的最小单测(《08》F-9:先给纯函数补单测,其余继续靠 E2E)。
*
* <p>选这三条规则断言,是因为每一条背后都有一个<b>真实踩过的坑</b>:
* <ul>
* <li>姓名去空白、但<b>不去间隔号</b> —— 少数民族姓名里的「·」是真实的;
* 顺手"清一清"会弄坏一部分人的名字(F-20 的另一面:规范化规则不能互相借用);</li>
* <li>全角转半角 —— 手机输入法常给全角数字,而它进校验位算法就是错的;</li>
* <li>证件号小写 {@code x} 收尾要统一成大写 —— 名册比对按字符串等值。</li>
* </ul>
*/
class IdentityNormalizerTest {
@Test
void 姓名去所有空白_含全角空格() {
assertEquals("张三", IdentityNormalizer.name("张 三"));
assertEquals("张三", IdentityNormalizer.name("张 三")); // 全角空格 U+3000
assertEquals("张三", IdentityNormalizer.name(" 张\t三\n"));
}
@Test
void 姓名里的间隔号必须保留() {
// 阿依古丽·买买提:间隔号是姓名的一部分,清掉等于改了人家的名字
assertEquals("阿依古丽·买买提", IdentityNormalizer.name("阿依古丽·买买提"));
assertEquals("阿依古丽·买买提", IdentityNormalizer.name("阿依古丽 · 买买提"));
}
@Test
void 全角ASCII转半角_其余不动() {
assertEquals("123X", IdentityNormalizer.toHalfWidth("123X"));
assertEquals("中文不变", IdentityNormalizer.toHalfWidth("中文不变"));
assertEquals("", IdentityNormalizer.toHalfWidth(null));
}
@Test
void 证件号去空白_转大写_全角可入() {
assertEquals("36102920090729511X", IdentityNormalizer.idCard("3610292009 0729511x"));
assertEquals("36102920090729511X", IdentityNormalizer.idCard("36102920090729511X"));
}
@Test
void 姓名空值返回空串而不是NPE() {
assertEquals("", IdentityNormalizer.name(null));
}
}