diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eGateLogicTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eGateLogicTest.java
new file mode 100644
index 00000000..af1a2530
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eGateLogicTest.java
@@ -0,0 +1,171 @@
+package com.wanxiang.huijing.game.module.aigc.saa;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+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;
+
+/**
+ * SaaFullGraphE2eGateLogicTest —— 003-U1 验收门工具化的纯逻辑模型无关单测。
+ *
+ *
为何独立成测:{@link SaaFullGraphE2eTest} 主体 {@code @EnabledIfSystemProperty(saa.e2e=1)}(需 mini-desktop+模型+九门,
+ * 普通构建跳过、不可验)。但其新增的「brief 解析 / 取前 N / 门槛解析 / 成功率门 / token·模型档抽取」全是纯函数,
+ * 与模型无关——抽出后此测在普通 {@code mvn test} 即跑,无需 LLM / NEWAPI_KEY / -Dsaa.e2e,作工具化逻辑的客观回归门。
+ *
+ *
覆盖:happy + 边界(空/null/越界/非法)+ 等价类,对齐 ce-work「Test Scenario Completeness」。
+ *
+ * @author 造梦AI(003-U1 验收门工具化)
+ */
+class SaaFullGraphE2eGateLogicTest {
+
+ // ─────────────────────────── parseBriefLines:行式解析(去空白 / 跳空行 / 跳 # 注释) ───────────────────────────
+
+ @Test
+ @DisplayName("parseBriefLines:去首尾空白 + 跳空行 + 跳 # 注释 + 保留正文")
+ void parseBriefLines_basic() {
+ List raw = Arrays.asList(
+ "# 这是注释,应跳过",
+ " 打砖块:挡板接球消砖。 ", // 含首尾空白,应 trim
+ "", // 空行,跳过
+ " ", // 纯空白行,跳过
+ "贪吃蛇:吃食物变长。",
+ "#另一条注释",
+ "接金币:接住金币。");
+ List out = SaaFullGraphE2eTest.parseBriefLines(raw);
+ assertEquals(3, out.size(), "应只剩 3 条正文");
+ assertEquals("打砖块:挡板接球消砖。", out.get(0), "首尾空白应被 trim");
+ assertEquals("贪吃蛇:吃食物变长。", out.get(1));
+ assertEquals("接金币:接住金币。", out.get(2));
+ }
+
+ @Test
+ @DisplayName("parseBriefLines:null 入参 → 空列表(不抛)")
+ void parseBriefLines_null() {
+ assertTrue(SaaFullGraphE2eTest.parseBriefLines(null).isEmpty());
+ }
+
+ @Test
+ @DisplayName("parseBriefLines:含 null 行 + CRLF 尾 \\r 被 trim 掉")
+ void parseBriefLines_nullLineAndCrlf() {
+ List raw = Arrays.asList("有效行\r", null, "另一行");
+ List out = SaaFullGraphE2eTest.parseBriefLines(raw);
+ assertEquals(2, out.size());
+ assertEquals("有效行", out.get(0), "CRLF 的 \\r 应被 trim 掉(跨平台 brief 文件)");
+ }
+
+ // ─────────────────────────── clampToN:取前 N(缺=全量 / clamp [1,size]) ───────────────────────────
+
+ @Test
+ @DisplayName("clampToN:n=null → 全量")
+ void clampToN_nullN_full() {
+ List src = Arrays.asList("a", "b", "c");
+ assertEquals(3, SaaFullGraphE2eTest.clampToN(src, null).size());
+ }
+
+ @Test
+ @DisplayName("clampToN:n 越上界 → 收到 size;n<1 → 收到 1;n 居中 → n")
+ void clampToN_clamp() {
+ List src = Arrays.asList("a", "b", "c");
+ assertEquals(3, SaaFullGraphE2eTest.clampToN(src, 99).size(), "n>size → size");
+ assertEquals(1, SaaFullGraphE2eTest.clampToN(src, 0).size(), "n<1 → 1");
+ assertEquals(1, SaaFullGraphE2eTest.clampToN(src, -5).size(), "负 n → 1");
+ assertEquals(2, SaaFullGraphE2eTest.clampToN(src, 2).size(), "居中 → n");
+ }
+
+ @Test
+ @DisplayName("clampToN:null / 空列表 → 安全返回空")
+ void clampToN_nullOrEmpty() {
+ assertTrue(SaaFullGraphE2eTest.clampToN(null, 5).isEmpty());
+ assertTrue(SaaFullGraphE2eTest.clampToN(new ArrayList<>(), 5).isEmpty());
+ }
+
+ // ─────────────────────────── parseRate:门槛解析(null/非法→0;clamp [0,1]) ───────────────────────────
+
+ @Test
+ @DisplayName("parseRate:null/空/非法 → 0.0(不设硬门,仅存在性)")
+ void parseRate_offCases() {
+ assertEquals(0.0, SaaFullGraphE2eTest.parseRate(null));
+ assertEquals(0.0, SaaFullGraphE2eTest.parseRate(""));
+ assertEquals(0.0, SaaFullGraphE2eTest.parseRate(" "));
+ assertEquals(0.0, SaaFullGraphE2eTest.parseRate("abc"), "非法值按不设门处理,不抛");
+ }
+
+ @Test
+ @DisplayName("parseRate:合法值原样;越界 clamp 到 [0,1];带空白可解析")
+ void parseRate_validAndClamp() {
+ assertEquals(0.8, SaaFullGraphE2eTest.parseRate("0.8"));
+ assertEquals(0.5, SaaFullGraphE2eTest.parseRate(" 0.5 "), "首尾空白应 trim");
+ assertEquals(1.0, SaaFullGraphE2eTest.parseRate("1.5"), "上界 clamp 到 1.0");
+ assertEquals(0.0, SaaFullGraphE2eTest.parseRate("-0.2"), "负值 clamp 到 0.0");
+ }
+
+ // ─────────────────────────── passesGate:成功率硬门 ───────────────────────────
+
+ @Test
+ @DisplayName("passesGate:达门(≥,含正好等于)/ 不达门 / 无样本 / 门槛0")
+ void passesGate_cases() {
+ assertTrue(SaaFullGraphE2eTest.passesGate(8, 10, 0.8), "8/10=0.8 正好等于门槛 → 过(>=)");
+ assertTrue(SaaFullGraphE2eTest.passesGate(9, 10, 0.8), "9/10 > 0.8 → 过");
+ assertFalse(SaaFullGraphE2eTest.passesGate(7, 10, 0.8), "7/10 < 0.8 → 不过");
+ assertTrue(SaaFullGraphE2eTest.passesGate(10, 10, 1.0), "满分对 100% 门 → 过");
+ assertFalse(SaaFullGraphE2eTest.passesGate(1, 0, 0.8), "total<=0 无样本 → 不过(防 0 除)");
+ assertTrue(SaaFullGraphE2eTest.passesGate(0, 10, 0.0), "门槛 0 → 恒过(存在性另管)");
+ }
+
+ // ─────────────────────────── extractTotalTokens / extractModelTier:成本会计抽取 ───────────────────────────
+
+ @Test
+ @DisplayName("extractTotalTokens:tokens.{prompt,completion} 求和(Number/Long/数字串)")
+ void extractTotalTokens_sum() {
+ Map trace = new LinkedHashMap<>();
+ Map tokens = new LinkedHashMap<>();
+ tokens.put("prompt", 1200L); // dispatcher 落 Long
+ tokens.put("completion", 800); // 兼容 Integer
+ trace.put("tokens", tokens);
+ assertEquals(2000L, SaaFullGraphE2eTest.extractTotalTokens(trace));
+
+ // 数字串容错
+ Map strTokens = new HashMap<>();
+ strTokens.put("prompt", "300");
+ strTokens.put("completion", "20");
+ Map trace2 = new HashMap<>();
+ trace2.put("tokens", strTokens);
+ assertEquals(320L, SaaFullGraphE2eTest.extractTotalTokens(trace2));
+ }
+
+ @Test
+ @DisplayName("extractTotalTokens:缺 prompt → 仅 completion;trace=null / 无 tokens / tokens 非 Map → -1(无数据)")
+ void extractTotalTokens_missing() {
+ Map onlyCompletion = new HashMap<>();
+ onlyCompletion.put("completion", 50L);
+ Map trace = new HashMap<>();
+ trace.put("tokens", onlyCompletion);
+ assertEquals(50L, SaaFullGraphE2eTest.extractTotalTokens(trace), "缺 prompt → 按 0 + completion");
+
+ assertEquals(-1L, SaaFullGraphE2eTest.extractTotalTokens(null), "trace=null → -1");
+ assertEquals(-1L, SaaFullGraphE2eTest.extractTotalTokens(new HashMap<>()), "无 tokens 键 → -1");
+ Map badTokens = new HashMap<>();
+ badTokens.put("tokens", "not-a-map");
+ assertEquals(-1L, SaaFullGraphE2eTest.extractTotalTokens(badTokens), "tokens 非 Map → -1");
+ }
+
+ @Test
+ @DisplayName("extractModelTier:有 stage2 → stage2;缺 / trace=null → stage1(未升档)")
+ void extractModelTier_cases() {
+ Map stage2 = new HashMap<>();
+ stage2.put("modelTier", "stage2");
+ assertEquals("stage2", SaaFullGraphE2eTest.extractModelTier(stage2));
+
+ assertEquals("stage1", SaaFullGraphE2eTest.extractModelTier(new HashMap<>()), "缺 modelTier → stage1");
+ assertEquals("stage1", SaaFullGraphE2eTest.extractModelTier(null), "trace=null → stage1");
+ }
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java
index 8d28e5cd..00a39469 100644
--- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java
@@ -44,21 +44,30 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
*
* - 门控 {@code -Dsaa.e2e=1}(无机/无 key 时不跑,避免误判 fail);
* - {@code saaCheckpointEnabled=false} + DataSource 传 null → 零 DB 依赖;
- * - N 默认 5,可 {@code -Dsaa.e2e.n=K} 覆盖(串行单条含修复轮约 5~15 分钟,N=5 需约 30~75 分钟,按 mvn timeout 量裁);
+ * - brief 源:默认内置多品类(≥10 条),或 {@code -Dsaa.e2e.briefFile=<路径>} 外部文件
+ * (行式:去首尾空白、跳空行与 {@code #} 注释行;解析为空则回退内置);可 {@code -Dsaa.e2e.n=K} 取前 K 条
+ * (串行单条含修复轮约 5~15 分钟,N=10 需约 50~150 分钟,按 mvn timeout 量裁);
* - 等待总超时 = {@code perBriefBudget(默认 900s) × N},可 {@code -Dsaa.e2e.perBriefSec} 调;到点仍未集齐则
* best-effort 收尾(已回调的照常计,未回调的计「(未回调)」),不一条卡死全挂;
- * - 成功率作证据打印,不硬断 80%——存在性断言放宽到「至少 1 条真全图 e2e 跑通」(首证存在性,成功率留人判)。
+ * - 成功率门(003-U1):成功率始终作证据打印 + 存在性下限「≥1 条跑通」始终断言;
+ * flip 前置硬门仅当显式 {@code -Dsaa.e2e.minSuccessRate=0.8} 设定时才硬断成功率≥门槛
+ * (默认不设=基线量测模式,保 003-U1「先实测真基线再收敛」,不破普通构建);
+ * - 成本会计(003-U1·token 代理):汇总每条 prompt+completion token + 升 stage2(强档救场)计数作成本证据;
+ * {@code -Dsaa.e2e.maxTokensPerGame=N} 设上限时仅软警告不硬阻断(遵创始人裁决「质量优先、允许临时破价、记账对账」)。
+ * 注:SAA 路只有 token 计量,折¥是 follow-up F3({@code SaaGraphDispatcher} cost 整段省略,§6.3)→ 此处不造假¥。
*
*
* 运行(mini-desktop,game-runtime 有 node+esbuild+Chrome、端口 4320/9222 空):
*
+ * // 基线量测(仅打印成功率 + 存在性断言;003-U1 第一步「实测真基线」):
* NEWAPI_KEY=sk-... timeout 6000 mvn -pl ...aigc-server test \
- * -Dtest=SaaFullGraphE2eTest -Dsaa.e2e=1 \
+ * -Dtest=SaaFullGraphE2eTest -Dsaa.e2e=1 -Dsaa.e2e.n=10 \
* -DGAME_RUNTIME_DIR=/root/game-staging/repo/game-runtime \
* -Dsurefire.failIfNoSpecifiedTests=false
+ * // flip 前置硬门(成功率须≥80%,否则测试 fail):追加 -Dsaa.e2e.minSuccessRate=0.8
*
*
- * @author 造梦AI(U7 集成顶石)
+ * @author 造梦AI(U7 集成顶石;003-U1 验收门工具化)
*/
class SaaFullGraphE2eTest {
@@ -77,15 +86,23 @@ class SaaFullGraphE2eTest {
Long.getLong("saa.e2e.perBriefSec", 900L);
/**
- * N=5 条多样一句话 brief(覆盖不同玩法品类,避免单品类偶然过/不过)。可 {@code -Dsaa.e2e.n=K} 取前 K 条。
- * 全部一次性投递,后台真玩门 Semaphore(1) 串行逐条跑。
+ * 内置 12 条多样一句话 brief(覆盖不同玩法品类,避免单品类偶然过/不过;003-U1 扩到 ≥10 以作产线门量测)。
+ * 偏向运行时/harness 支持的品类(realtime/physics/tap-targets/clickable)。可 {@code -Dsaa.e2e.n=K} 取前 K 条,
+ * 或 {@code -Dsaa.e2e.briefFile=<路径>} 整组改用外部文件。全部一次性投递,后台真玩门 Semaphore(1) 串行逐条跑。
*/
private static final List ALL_BRIEFS = List.of(
"打砖块:挡板接球把上方砖块全部消除即胜,掉球则负。",
"贪吃蛇:操控小蛇吃食物变长,撞到自己身体或墙壁则游戏结束。",
"接金币:左右移动篮子接住从天上掉落的金币,漏接太多就失败。",
"躲障碍:角色不断前进,点击跳跃躲开迎面而来的障碍物,撞到就结束。",
- "点击消除:点击三个或以上相连的同色方块把它们消掉,消满目标分数过关。"
+ "点击消除:点击三个或以上相连的同色方块把它们消掉,消满目标分数过关。",
+ "太空射击:飞船左右移动并发射子弹击落持续下降的敌机,被敌机撞到则失败。",
+ "打地鼠:地鼠随机从洞口冒头,点击敲中得分,限时结束时分数越高越好。",
+ "节奏敲击:方块下落到底部判定线的瞬间点击得分,连续错过三次则结束。",
+ "平台跳跃:左右移动并跳跃,踩着悬空平台抵达终点旗帜,掉出屏幕底部则失败。",
+ "太空躲陨石:上下左右移动飞船躲避飞来的陨石,坚持到计时归零即通关。",
+ "弹力打靶:点击屏幕给小球施加方向,把场上所有靶子撞掉即过关。",
+ "泡泡射手:瞄准并发射泡泡,三个同色相连则消除,清空全部泡泡即胜。"
);
/** 单条 brief 的 e2e 结果证据(回调按 traceId 精确归位)。 */
@@ -99,6 +116,8 @@ class SaaFullGraphE2eTest {
volatile boolean dispatched = false; // dispatch 投递握手是否 true
volatile long callbackAtMs = 0; // 回调命中时刻(相对 run 起点;推单条墙钟用)
volatile String failDetail = ""; // 失败时从 verdict.json/giveup-dump.json 摘的真因
+ volatile long totalTokens = -1; // 成本会计:prompt+completion token(成本代理);-1=回调未带 trace token
+ volatile String modelTier = "stage1"; // 终态模型档(stage1/stage2);stage2=救场升强档(成本↑,关注质量-成本权衡)
BriefResult(int idx, String brief, String traceId) {
this.idx = idx;
@@ -136,9 +155,18 @@ class SaaFullGraphE2eTest {
r.engineBundleLen = reqVO.getEngineBundle() == null ? 0 : reqVO.getEngineBundle().length();
r.failureReason = reqVO.getFailureReason() == null ? "" : reqVO.getFailureReason();
r.callbackAtMs = System.currentTimeMillis() - runStartMs;
+ // 成本会计(003-U1):从 trace best-effort 抽 token 总数 + 模型档(缺/异常保持默认 -1/stage1,不阻断归位)。
+ Object traceObj = reqVO.getTrace();
+ if (traceObj instanceof Map) {
+ @SuppressWarnings("unchecked")
+ Map tr = (Map) traceObj;
+ r.totalTokens = extractTotalTokens(tr);
+ r.modelTier = extractModelTier(tr);
+ }
System.out.println("[callback] #" + r.idx + " traceId=" + traceId
+ " status=" + r.status + " engineBundleLen=" + r.engineBundleLen
- + " reason=" + r.failureReason + " @+" + (r.callbackAtMs / 1000) + "s");
+ + " reason=" + r.failureReason + " tokens=" + r.totalTokens + " tier=" + r.modelTier
+ + " @+" + (r.callbackAtMs / 1000) + "s");
} else {
// 不应发生(traceId 必在 map 内):留痕,仍倒数避免 latch 永挂。
System.out.println("[callback] 未知 traceId=" + traceId + "(不在 map 内,忽略归位仍倒数)");
@@ -163,10 +191,12 @@ class SaaFullGraphE2eTest {
assertTrue(Files.exists(runtimeRoot.resolve("node_modules/esbuild")),
"缺 node_modules/esbuild(build 节点依赖),请在 game-runtime 跑 npm i -D esbuild");
- // N:默认 5,可 -Dsaa.e2e.n=K 取前 K 条(串行耗时大,调小便于在短 mvn timeout 内跑通若干条)。
- int n = Integer.getInteger("saa.e2e.n", ALL_BRIEFS.size());
- n = Math.max(1, Math.min(n, ALL_BRIEFS.size()));
- List briefs = ALL_BRIEFS.subList(0, n);
+ // brief 源(003-U1):-Dsaa.e2e.briefFile 给定且可读→外部文件(行式),否则内置 12 条;
+ // 再按 -Dsaa.e2e.n=K 取前 K 条(未设=全量;串行耗时大,调小便于在短 mvn timeout 内跑通若干条)。
+ List sourceBriefs = resolveSourceBriefs();
+ List briefs = clampToN(sourceBriefs, Integer.getInteger("saa.e2e.n"));
+ int n = briefs.size();
+ System.out.println("[briefs] 本次投递 n=" + n + " 条(源 " + sourceBriefs.size() + " 条)");
// ── 1) 构造真 AigcExecutorProperties(@Data 即 setter 全有)──
// checkpoint 关 + DataSource 传 null → 零 DB 依赖;dispatcher=saa 走进程内全图派发。
@@ -250,8 +280,10 @@ class SaaFullGraphE2eTest {
System.out.println("------------------------------------------------------");
for (BriefResult r : results) {
System.out.println(String.format(
- "#%d status=%-26s bundleLen=%-7d callback@+%-5s reason=%s",
+ "#%d status=%-26s bundleLen=%-7d tokens=%-7s tier=%-6s callback@+%-5s reason=%s",
r.idx, r.status, r.engineBundleLen,
+ (r.totalTokens >= 0 ? String.valueOf(r.totalTokens) : "-"),
+ r.modelTier,
(r.callbackAtMs > 0 ? (r.callbackAtMs / 1000) + "s" : "-"),
r.succeeded() ? "-" : (r.failureReason.isEmpty() ? "(无)" : r.failureReason)));
System.out.println(" brief: " + r.brief);
@@ -259,12 +291,56 @@ class SaaFullGraphE2eTest {
System.out.println(" fail : " + r.failDetail);
}
}
+ // ── 成本会计(003-U1·token 代理,非¥;折¥=follow-up F3,SAA 路 cost 省略)──
+ long totalTok = 0;
+ int tokCounted = 0;
+ int stage2Count = 0;
+ for (BriefResult r : results) {
+ if (r.totalTokens >= 0) {
+ totalTok += r.totalTokens;
+ tokCounted++;
+ }
+ if ("stage2".equals(r.modelTier)) {
+ stage2Count++;
+ }
+ }
+ System.out.println("------------------------------------------------------");
+ System.out.println("成本会计(token 代理,非¥;折¥=follow-up F3):");
+ System.out.println(" 总 token = " + totalTok + "(有数据 " + tokCounted + "/" + n + " 条)"
+ + (tokCounted > 0 ? " 均 " + (totalTok / tokCounted) + " token/款" : ""));
+ System.out.println(" 升 stage2(强档救场)= " + stage2Count + "/" + n + " 条"
+ + (stage2Count > 0 ? "(强档↑成本,关注质量-成本权衡 KTD5)" : ""));
+ // -Dsaa.e2e.maxTokensPerGame=N:仅软警告超预算条目,不硬阻断(遵创始人「质量优先、允许临时破价、记账对账」)。
+ Long maxTok = Long.getLong("saa.e2e.maxTokensPerGame");
+ if (maxTok != null) {
+ for (BriefResult r : results) {
+ if (r.totalTokens > maxTok) {
+ System.out.println(" [warn] #" + r.idx + " token " + r.totalTokens
+ + " 超预算 " + maxTok + "(软警告,不阻断——质量优先允许临时破价,见 KTD5)");
+ }
+ }
+ }
System.out.println("######################################################");
- // ── 7) 存在性断言(首证):至少 1 条真全图 e2e 跑通;成功率作证据,不硬断 80%(留人判)──
+ // ── 7) 断言(003-U1 验收门)──
+ // 7a) 存在性下限(首证 / 基线量测模式):至少 1 条跑通,始终生效。
assertTrue(succeeded >= 1,
- "至少 1 条 SAA 真全图 e2e 应跑通(实得 " + succeeded + "/" + briefs.size()
+ "至少 1 条 SAA 真全图 e2e 应跑通(实得 " + succeeded + "/" + n
+ ");若全 0:检查 new-api 可达/九门 harness/端口 4320-9222 占用/图终态——见上方逐条 failDetail");
+ // 7b) flip 前置硬门:仅当显式 -Dsaa.e2e.minSuccessRate 设定时才硬断成功率≥门槛(默认不设=仅存在性,保「先实测真基线」)。
+ double minSuccessRate = parseRate(System.getProperty("saa.e2e.minSuccessRate"));
+ if (minSuccessRate > 0.0) {
+ assertTrue(passesGate((int) succeeded, n, minSuccessRate),
+ String.format("成功率 %.1f%%(%d/%d)< flip 前置门槛 %.1f%% —— 硬门未过,不可切默认产线(003-U2);"
+ + "据根因分析按主杠杆(升模型档 + ReAct)继续收敛",
+ 100.0 * succeeded / n, succeeded, n, minSuccessRate * 100));
+ System.out.println("[gate] flip 前置硬门通过:成功率 "
+ + String.format("%.1f%%", 100.0 * succeeded / n) + " ≥ 门槛 "
+ + String.format("%.1f%%", minSuccessRate * 100));
+ } else {
+ System.out.println("[gate] 未设 -Dsaa.e2e.minSuccessRate:仅存在性断言(基线量测模式);"
+ + "flip 前置硬门请加 -Dsaa.e2e.minSuccessRate=0.8");
+ }
}
/**
@@ -319,4 +395,160 @@ class SaaFullGraphE2eTest {
return "(读 failDetail 异常:" + e.getMessage() + ")";
}
}
+
+ // ───────────────── 003-U1 验收门工具化:纯逻辑静态方法(模型无关,可单测,见 SaaFullGraphE2eGateLogicTest) ─────────────────
+
+ /**
+ * 决定本次 brief 列表来源:{@code -Dsaa.e2e.briefFile} 给定且可读 → 外部文件(经 {@link #parseBriefLines} 解析),
+ * 否则用内置 {@link #ALL_BRIEFS}。外部文件解析为空或读取失败 → warn + 回退内置(不抛、不致测试无 brief 可投)。
+ *
+ * @return 源 brief 列表(外部文件 or 内置;恒非空)
+ */
+ private static List resolveSourceBriefs() {
+ String briefFile = System.getProperty("saa.e2e.briefFile");
+ if (briefFile != null && !briefFile.isBlank()) {
+ Path p = Paths.get(briefFile.trim());
+ try {
+ List parsed = parseBriefLines(Files.readAllLines(p));
+ if (!parsed.isEmpty()) {
+ System.out.println("[briefs] 外部文件 " + p + " 载入 " + parsed.size() + " 条");
+ return parsed;
+ }
+ System.out.println("[briefs][warn] 外部文件 " + p + " 解析后为空,回退内置 " + ALL_BRIEFS.size() + " 条");
+ } catch (Exception e) {
+ System.out.println("[briefs][warn] 读外部文件失败(" + e.getMessage() + "),回退内置 " + ALL_BRIEFS.size() + " 条");
+ }
+ }
+ return ALL_BRIEFS;
+ }
+
+ /**
+ * 解析外部 brief 文件行(纯函数):逐行去首尾空白,跳过空行与 {@code #} 注释行;其余原样保留。
+ *
+ * @param rawLines 原始行(null 视作空)
+ * @return 清洗后的 brief 列表(恒非空对象,可能 0 元素)
+ */
+ static List parseBriefLines(List rawLines) {
+ List out = new ArrayList<>();
+ if (rawLines == null) {
+ return out;
+ }
+ for (String line : rawLines) {
+ if (line == null) {
+ continue;
+ }
+ String t = line.trim();
+ if (t.isEmpty() || t.startsWith("#")) {
+ continue; // 跳空行 / # 注释行
+ }
+ out.add(t);
+ }
+ return out;
+ }
+
+ /**
+ * 取前 N 条(纯函数):{@code nOrNull} 缺(null)→ 全量;否则 clamp 到 {@code [1, size]}。空列表安全返回。
+ *
+ * @param briefs 源列表
+ * @param nOrNull -Dsaa.e2e.n(未设为 null)
+ * @return 前 N 条子列表
+ */
+ static List clampToN(List briefs, Integer nOrNull) {
+ if (briefs == null) {
+ return new ArrayList<>();
+ }
+ if (briefs.isEmpty()) {
+ return briefs;
+ }
+ int n = (nOrNull == null) ? briefs.size() : nOrNull;
+ n = Math.max(1, Math.min(n, briefs.size()));
+ return briefs.subList(0, n);
+ }
+
+ /**
+ * 解析成功率门槛(纯函数):null/空/非法 → 0.0(=不设硬门,仅存在性断言);否则 clamp 到 {@code [0,1]}。
+ *
+ * @param raw -Dsaa.e2e.minSuccessRate 原始串
+ * @return 门槛 [0,1]
+ */
+ static double parseRate(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return 0.0;
+ }
+ try {
+ double v = Double.parseDouble(raw.trim());
+ if (v < 0) {
+ return 0.0;
+ }
+ return Math.min(v, 1.0);
+ } catch (NumberFormatException e) {
+ return 0.0; // 非法值按「不设门」处理(基线量测模式),不致测试构造期抛
+ }
+ }
+
+ /**
+ * 成功率硬门判定(纯函数):{@code total<=0} → false(无样本不算过门);否则 {@code succeeded/total >= minRate}。
+ *
+ * @param succeeded 跑通条数
+ * @param total 总投递条数
+ * @param minRate 门槛 [0,1]
+ * @return 是否达门
+ */
+ static boolean passesGate(int succeeded, int total, double minRate) {
+ if (total <= 0) {
+ return false;
+ }
+ return ((double) succeeded / total) >= minRate;
+ }
+
+ /**
+ * 从回调 {@code trace} map 抽 token 总数(纯函数):{@code trace.tokens.{prompt,completion}} 之和。
+ * SAA 路恒落 tokens(缺省 0);trace 为 null / 无 tokens 子对象 → 返 {@code -1}(表示「无数据」,区别于真 0)。
+ *
+ * @param trace 回调 trace({@link DifyCallbackReqVO#getTrace()})
+ * @return prompt+completion token 总数,无数据返 -1
+ */
+ static long extractTotalTokens(Map trace) {
+ if (trace == null) {
+ return -1;
+ }
+ Object tk = trace.get("tokens");
+ if (!(tk instanceof Map)) {
+ return -1;
+ }
+ Map, ?> tokens = (Map, ?>) tk;
+ return asLong(tokens.get("prompt")) + asLong(tokens.get("completion"));
+ }
+
+ /**
+ * 从回调 {@code trace} map 抽模型档(纯函数):{@code trace.modelTier}("stage1"/"stage2")。
+ * dispatcher 仅「真升档态」才落 modelTier → 缺(含 trace=null)即 stage1(未升档)。
+ *
+ * @param trace 回调 trace
+ * @return "stage1" 或 "stage2"
+ */
+ static String extractModelTier(Map trace) {
+ if (trace == null) {
+ return "stage1";
+ }
+ Object mt = trace.get("modelTier");
+ return mt == null ? "stage1" : String.valueOf(mt);
+ }
+
+ /**
+ * 宽松转 long(trace token 取值专用):Number → longValue;数字串 → parse;其余/null → 0。
+ */
+ private static long asLong(Object o) {
+ if (o instanceof Number) {
+ return ((Number) o).longValue();
+ }
+ if (o instanceof String) {
+ try {
+ return Long.parseLong(((String) o).trim());
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+ return 0;
+ }
}