diff --git a/backend/xt-server/src/test/java/cn/tianbaotong/fill/module/file/service/FileProcessServiceTest.java b/backend/xt-server/src/test/java/cn/tianbaotong/fill/module/file/service/FileProcessServiceTest.java
new file mode 100644
index 0000000..7c0f61e
--- /dev/null
+++ b/backend/xt-server/src/test/java/cn/tianbaotong/fill/module/file/service/FileProcessServiceTest.java
@@ -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)。
+ *
+ *
断言的是有理由的规则,不是覆盖率:
+ *
+ * - 扩展名必须自带点号(F-46:少一个字符不报错,只会产出「身份证jpg」这种
+ * 客户双击打不开的文件);
+ * - 空变量用「未填」占位,而不是留空 —— 留空会出现「张同学--身份证.jpg」,
+ * 看起来像系统出错;
+ * - 文件名里的 {@code /} 必须被清掉(学生的「学籍表(1/2)」很常见,
+ * 带 / 的名字根本写不进磁盘);
+ * - 超长截断 —— Windows 路径 260 限制,目录还要留空间。
+ *
+ */
+class FileProcessServiceTest {
+
+ private final FileProcessService svc = new FileProcessService();
+
+ private static Map vars(String name, String id, String material) {
+ Map 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 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"));
+ }
+}
diff --git a/backend/xt-server/src/test/java/cn/tianbaotong/fill/support/IdentityNormalizerTest.java b/backend/xt-server/src/test/java/cn/tianbaotong/fill/support/IdentityNormalizerTest.java
new file mode 100644
index 0000000..2fbc550
--- /dev/null
+++ b/backend/xt-server/src/test/java/cn/tianbaotong/fill/support/IdentityNormalizerTest.java
@@ -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)。
+ *
+ * 选这三条规则断言,是因为每一条背后都有一个真实踩过的坑:
+ *
+ * - 姓名去空白、但不去间隔号 —— 少数民族姓名里的「·」是真实的;
+ * 顺手"清一清"会弄坏一部分人的名字(F-20 的另一面:规范化规则不能互相借用);
+ * - 全角转半角 —— 手机输入法常给全角数字,而它进校验位算法就是错的;
+ * - 证件号小写 {@code x} 收尾要统一成大写 —— 名册比对按字符串等值。
+ *
+ */
+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));
+ }
+}