|
|
@@ -0,0 +1,180 @@
|
|
|
+package com.payment.platform.module.payment.unified;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.io.IOException;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.Path;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.regex.Matcher;
|
|
|
+import java.util.regex.Pattern;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 统一支付开放文档的静默失效防线(spec §10.2 / §10.3)。
|
|
|
+ *
|
|
|
+ * <p>{@code buildSections} 在 {@code parts} 的标题与 md 对不上时**整条丢弃**,页面不报错、
|
|
|
+ * 只是少一条 —— 所以这里逐字核对:8 个 H2 标题各恰好一次、切片器的 8 个 key 与 parts
|
|
|
+ * 与标题一一对应、16 个 wayCode 与关键事实都在正文里、侧边栏 7 组按 R52 顺序。
|
|
|
+ *
|
|
|
+ * <p>★ CRLF 警告:本仓 {@code core.autocrlf=true}、无 .gitattributes,工作区的 md 是 CRLF。
|
|
|
+ * 必须先 {@code replace("\r\n", "\n")} 归一化再按行切;{@code split("\n")} 配 {@code $}
|
|
|
+ * 锚定会一行标题都匹配不上({@code docSlicer.ts} 2026-09-24 踩过同款,侧边栏整组静默清空)。
|
|
|
+ *
|
|
|
+ * <p>Surefire 的工作目录是模块根 {@code java/},故用相对路径读前端源码。
|
|
|
+ */
|
|
|
+class UnifiedDocSectionsTest {
|
|
|
+
|
|
|
+ private static final Path DOC_TS =
|
|
|
+ Path.of("../frontend/src/views/module_payment/apikey/unifiedDoc.ts");
|
|
|
+ private static final Path DOC_MD =
|
|
|
+ Path.of("../frontend/src/views/module_payment/apikey/docs/unified-openapi.md");
|
|
|
+ private static final Path INDEX_VUE =
|
|
|
+ Path.of("../frontend/src/views/module_payment/apikey/index.vue");
|
|
|
+
|
|
|
+ /** brief 的 8 个 H2,逐字(含序号与空格)。 */
|
|
|
+ private static final List<String> EXPECTED_H2 = List.of(
|
|
|
+ "## 1. 概述",
|
|
|
+ "## 2. 统一下单",
|
|
|
+ "## 3. 订单查询",
|
|
|
+ "## 4. 统一退款",
|
|
|
+ "## 5. 退款查询",
|
|
|
+ "## 6. 获取渠道用户 ID",
|
|
|
+ "## 7. 异步通知",
|
|
|
+ "## 8. 联调检查清单");
|
|
|
+
|
|
|
+ /** spec §10.2 的 key,顺序即侧边栏顺序。 */
|
|
|
+ private static final List<String> EXPECTED_KEYS = List.of(
|
|
|
+ "unified_overview",
|
|
|
+ "unified_create",
|
|
|
+ "unified_query",
|
|
|
+ "unified_refund",
|
|
|
+ "unified_refund_query",
|
|
|
+ "unified_channel_user",
|
|
|
+ "unified_notify",
|
|
|
+ "unified_checklist");
|
|
|
+
|
|
|
+ private static final List<String> WAY_CODES = List.of(
|
|
|
+ "QR_CASHIER", "AUTO_BAR", "ALI_BAR", "ALI_JSAPI", "ALI_APP", "ALI_WAP", "ALI_PC", "ALI_QR",
|
|
|
+ "WX_BAR", "WX_JSAPI", "WX_LITE", "WX_APP", "WX_H5", "WX_NATIVE", "YSF_BAR", "YSF_JSAPI");
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void md恰好有八个H2标题且每个恰好一次() throws IOException {
|
|
|
+ List<String> h2 = h2Lines(readNormalized(DOC_MD));
|
|
|
+
|
|
|
+ assertEquals(EXPECTED_H2.size(), h2.size(), "H2 标题数量不是 8(第 9 个 H2 即失败): " + h2);
|
|
|
+ for (String title : EXPECTED_H2) {
|
|
|
+ assertEquals(1, h2.stream().filter(title::equals).count(),
|
|
|
+ "标题必须恰好出现一次: " + title + ",实际全量 H2=" + h2);
|
|
|
+ }
|
|
|
+ assertEquals(Set.copyOf(EXPECTED_H2), new HashSet<>(h2), "H2 标题集合与约定不一致: " + h2);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 切片器八个键与parts逐字对应md标题() throws IOException {
|
|
|
+ String ts = readNormalized(DOC_TS);
|
|
|
+
|
|
|
+ List<String> keys = matches(ts, Pattern.compile("key:\\s*\"([^\"]+)\""));
|
|
|
+ assertEquals(EXPECTED_KEYS, keys, "8 个 key 必须按 spec §10.2 顺序且逐字");
|
|
|
+ assertEquals(8, new HashSet<>(keys).size(), "8 个 key 不得重复");
|
|
|
+ assertTrue(keys.stream().noneMatch(String::isBlank), "key 不得为空");
|
|
|
+
|
|
|
+ List<String> parts = matches(ts, Pattern.compile("parts:\\s*\\[\\s*\"([^\"]+)\"\\s*\\]"));
|
|
|
+ assertEquals(EXPECTED_H2, parts, "parts 必须逐字等于 8 个 H2 标题(含 `## ` 前缀)");
|
|
|
+
|
|
|
+ assertTrue(ts.contains("buildSections(raw, SECTIONS)"), "必须复用 docSlicer 的 buildSections");
|
|
|
+ assertTrue(ts.contains("export const unifiedSections"), "必须导出 unifiedSections");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正文覆盖十六种wayCode与九条事实的关键片段() throws IOException {
|
|
|
+ String md = readNormalized(DOC_MD);
|
|
|
+
|
|
|
+ assertTrue(md.contains("16 种"), "正文必须写明「16 种」wayCode");
|
|
|
+ for (String wayCode : WAY_CODES) {
|
|
|
+ assertTrue(md.contains(wayCode), "正文缺 wayCode: " + wayCode);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 九条事实的可检索锚点(逐条对应 brief 第 28-37 行)
|
|
|
+ for (String token : List.of(
|
|
|
+ "另一条产品线", "API Key", "分", "channelExtra", "幂等",
|
|
|
+ "notify_id", "最多投递 3 次", "可去重", "服务端", "pay_data", "http/https", "unit")) {
|
|
|
+ assertTrue(md.contains(token), "正文缺关键片段: " + token);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 侧边栏七组按R52顺序且默认全部收起() throws IOException {
|
|
|
+ String vue = readNormalized(INDEX_VUE);
|
|
|
+
|
|
|
+ for (String group : List.of("auth", "account", "callback", "har", "collection", "unified", "other")) {
|
|
|
+ assertTrue(vue.contains("name=\"" + group + "\""),
|
|
|
+ "侧边栏缺 el-collapse-item name=\"" + group + "\"");
|
|
|
+ }
|
|
|
+ assertEquals(7, countOccurrences(vue, "<el-collapse-item"),
|
|
|
+ "侧边栏应恰好 7 个 el-collapse-item");
|
|
|
+
|
|
|
+ int auth = vue.indexOf("name=\"auth\"");
|
|
|
+ int account = vue.indexOf("name=\"account\"");
|
|
|
+ int callback = vue.indexOf("name=\"callback\"");
|
|
|
+ int har = vue.indexOf("name=\"har\"");
|
|
|
+ int collection = vue.indexOf("name=\"collection\"");
|
|
|
+ int unified = vue.indexOf("name=\"unified\"");
|
|
|
+ int other = vue.indexOf("name=\"other\"");
|
|
|
+ assertTrue(auth < account && account < callback && callback < har && har < collection
|
|
|
+ && collection < unified && unified < other,
|
|
|
+ "7 组视觉顺序必须是 auth / account / callback / har / collection / unified / other");
|
|
|
+
|
|
|
+ assertTrue(vue.contains("const openedDocGroups = ref<string[]>([]);"),
|
|
|
+ "openedDocGroups 必须默认空数组(全部收起)");
|
|
|
+ assertTrue(vue.contains("v-model=\"openedDocGroups\""), "el-collapse 必须绑定 openedDocGroups");
|
|
|
+ assertTrue(vue.contains("const activeSection = ref(\"auth\");"),
|
|
|
+ "activeSection 默认仍必须是 auth");
|
|
|
+ assertTrue(vue.contains("...unifiedSections"), "mdSections 必须合并 unifiedSections");
|
|
|
+ assertTrue(vue.contains("v-for=\"(section, key) in unifiedSections\""),
|
|
|
+ "unified 组必须遍历 unifiedSections");
|
|
|
+ assertTrue(vue.contains("title=\"统一支付收单\""), "缺第 7 组标题「统一支付收单」");
|
|
|
+ assertTrue(vue.contains("v-if=\"SHOW_COLLECTION_DOC\""),
|
|
|
+ "collection 组原有的 SHOW_COLLECTION_DOC 开关必须保留");
|
|
|
+ }
|
|
|
+
|
|
|
+ private static List<String> h2Lines(String md) {
|
|
|
+ List<String> out = new ArrayList<>();
|
|
|
+ for (String line : md.split("\n")) {
|
|
|
+ if (line.startsWith("## ")) out.add(line);
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static List<String> matches(String text, Pattern pattern) {
|
|
|
+ List<String> out = new ArrayList<>();
|
|
|
+ Matcher m = pattern.matcher(text);
|
|
|
+ while (m.find()) out.add(m.group(1));
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int countOccurrences(String text, String needle) {
|
|
|
+ int count = 0;
|
|
|
+ int from = 0;
|
|
|
+ while (true) {
|
|
|
+ int at = text.indexOf(needle, from);
|
|
|
+ if (at < 0) return count;
|
|
|
+ count++;
|
|
|
+ from = at + needle.length();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 先断言存在,再读并把 CRLF 归一化成 LF —— 缺文件时给出「文件不存在」而不是 NPE。 */
|
|
|
+ private static String readNormalized(Path path) throws IOException {
|
|
|
+ assertNotNull(path, "路径为空");
|
|
|
+ assertTrue(Files.isRegularFile(path), "文件不存在: " + path.toAbsolutePath());
|
|
|
+ return Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n");
|
|
|
+ }
|
|
|
+}
|