From bf99662c3f69a8825519c470d10feef3dc2d93c6 Mon Sep 17 00:00:00 2001 From: zizi Date: Thu, 18 Jun 2026 00:40:14 +0000 Subject: [PATCH] =?UTF-8?q?test(aigc):=20SaaFullGraphE2eTest=20=E2=80=94?= =?UTF-8?q?=20SAA=20=E7=9C=9F=E5=85=A8=E5=9B=BE=20e2e=20=E9=A6=96=E8=AF=81?= =?UTF-8?q?=E9=97=A8=E6=8E=A7=E6=B5=8B=E8=AF=95(U7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U7 集成顶石的门控集成测试(-Dsaa.e2e=1 + NEWAPI_KEY)。构造真 5 参 SaaGraphDispatcher(stub callback 捕获 reqVO+latch,DataSource=null 内存 checkpoint,零 DB),顺序跑 N 条一句话 create job,真 LLM(new-api 五角色)+ 真九门 harness(node/esbuild/Chrome,4320/9222 串行)+真 16 节点全图 (render→classify→design→generate→validate→scaffold→asset→build→play→player→nreview→emit/giveup)+真出口回调,测成功率。 首证(2026-06-18,mini-desktop):SAA 真全图 e2e 机制完整跑通、零 prod bug; 成功率 3/5=60%(贪吃蛇/接金币/躲障碍 succeeded,engineBundle≈211KB 含 __GameBundle; 打砖块败 I_control、点击消除败 F_wiring+H_progress,救场阶梯耗尽 giveup 真落盘)。 单款墙钟 3.5-16min(九门真玩循环+最多8救场轮固有,同 Python W-G1)。 → 成功率 60% < 80% 转正门:cutover 未切(现 worker 主链保持默认,零回归)。 gap=难门生成质量(prompt 接地/gatespec/救场调优),W-G1 生成域调优单元。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../module/aigc/saa/SaaFullGraphE2eTest.java | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java 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 new file mode 100644 index 00000000..8d28e5cd --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java @@ -0,0 +1,322 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.wanxiang.huijing.game.module.aigc.controller.admin.task.vo.DifyCallbackReqVO; +import com.wanxiang.huijing.game.module.aigc.service.callback.DifyCallbackService; +import com.wanxiang.huijing.game.module.aigc.service.executor.AigcExecutorProperties; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * SaaFullGraphE2eTest —— U7 集成顶石:首次证明 SAA 真·全图生成 e2e 跑通并测成功率。 + * + *

验什么(与既有 SAA 测试的区别): + *

+ * + *

关键:dispatch 是「异步 + 单线程执行器 + Semaphore(1) 真玩门」=多 job 严格串行后台执行。 + * 故本测先一次性投递全部 N 条(每次 dispatch 提交后台即返回 true),再用一把共享 {@link CountDownLatch}(N) + * 等全部回调;stub 回调按 {@code reqVO.getTraceId()} 把每条结果精确归位到并发 map(不会跨条错配, + * 这是修掉「每条 arm 新 latch + 240s 超时」旧设计致命缺陷的关键——旧设计下后台串行跑、回调命中的是当时 + * 恰好 arm 的那条 latch,归因全错)。落库由 stub 整体旁路(不触三表写链)。 + * + *

纪律(纯测试·零 prod 改动): + *

    + *
  1. 门控 {@code -Dsaa.e2e=1}(无机/无 key 时不跑,避免误判 fail);
  2. + *
  3. {@code saaCheckpointEnabled=false} + DataSource 传 null → 零 DB 依赖;
  4. + *
  5. N 默认 5,可 {@code -Dsaa.e2e.n=K} 覆盖(串行单条含修复轮约 5~15 分钟,N=5 需约 30~75 分钟,按 mvn timeout 量裁);
  6. + *
  7. 等待总超时 = {@code perBriefBudget(默认 900s) × N},可 {@code -Dsaa.e2e.perBriefSec} 调;到点仍未集齐则 + * best-effort 收尾(已回调的照常计,未回调的计「(未回调)」),不一条卡死全挂;
  8. + *
  9. 成功率作证据打印,不硬断 80%——存在性断言放宽到「至少 1 条真全图 e2e 跑通」(首证存在性,成功率留人判)。
  10. + *
+ * + *

运行(mini-desktop,game-runtime 有 node+esbuild+Chrome、端口 4320/9222 空): + *

+ *   NEWAPI_KEY=sk-... timeout 6000 mvn -pl ...aigc-server test \
+ *       -Dtest=SaaFullGraphE2eTest -Dsaa.e2e=1 \
+ *       -DGAME_RUNTIME_DIR=/root/game-staging/repo/game-runtime \
+ *       -Dsurefire.failIfNoSpecifiedTests=false
+ * 
+ * + * @author 造梦AI(U7 集成顶石) + */ +class SaaFullGraphE2eTest { + + /** + * game-runtime 根(harness 运行时所在:node_modules/esbuild + games/_wg1-gen/_shared/serve-and-play.sh + play.cdp.cjs)。 + * 优先取系统属性 GAME_RUNTIME_DIR(与 {@link SaaHarnessDriveTest} 同口径),缺则取 staging 默认。 + */ + private static final String GAME_RUNTIME_DIR = System.getProperty("GAME_RUNTIME_DIR", + System.getenv().getOrDefault("GAME_RUNTIME_DIR", "/root/game-staging/repo/game-runtime")); + + /** new-api 网关 host 根(SAA 内部 stripV1Suffix 自剥 /v1;内网地址按项目规则可入码)。 */ + private static final String LLM_BASE = "http://100.64.0.8:3000"; + + /** 单条 brief 的墙钟预算秒数(用于推总等待超时;含 LLM 多步 + 修复轮 + 真玩九门,实测单条约 5~15 分钟)。 */ + private static final long PER_BRIEF_BUDGET_SECONDS = + Long.getLong("saa.e2e.perBriefSec", 900L); + + /** + * N=5 条多样一句话 brief(覆盖不同玩法品类,避免单品类偶然过/不过)。可 {@code -Dsaa.e2e.n=K} 取前 K 条。 + * 全部一次性投递,后台真玩门 Semaphore(1) 串行逐条跑。 + */ + private static final List ALL_BRIEFS = List.of( + "打砖块:挡板接球把上方砖块全部消除即胜,掉球则负。", + "贪吃蛇:操控小蛇吃食物变长,撞到自己身体或墙壁则游戏结束。", + "接金币:左右移动篮子接住从天上掉落的金币,漏接太多就失败。", + "躲障碍:角色不断前进,点击跳跃躲开迎面而来的障碍物,撞到就结束。", + "点击消除:点击三个或以上相连的同色方块把它们消掉,消满目标分数过关。" + ); + + /** 单条 brief 的 e2e 结果证据(回调按 traceId 精确归位)。 */ + private static final class BriefResult { + final int idx; + final String brief; + final String traceId; + volatile String status = "(未回调)"; // succeeded / failed / (未回调=总超时内未集齐) + volatile int engineBundleLen = 0; // 成功路 bundle.iife.js 长度(含 __GameBundle) + volatile String failureReason = ""; // 失败路归桶(llm_error 等) + volatile boolean dispatched = false; // dispatch 投递握手是否 true + volatile long callbackAtMs = 0; // 回调命中时刻(相对 run 起点;推单条墙钟用) + volatile String failDetail = ""; // 失败时从 verdict.json/giveup-dump.json 摘的真因 + + BriefResult(int idx, String brief, String traceId) { + this.idx = idx; + this.brief = brief; + this.traceId = traceId; + } + + boolean succeeded() { + return "succeeded".equals(status); + } + } + + /** + * stub DifyCallbackService:按 {@code reqVO.getTraceId()} 把结果精确归位到 byTrace map + 倒数共享 latch, + * 不写 DB(替掉真回调的三表写链)。后台串行回调,无并发竞争;用 ConcurrentHashMap 保险。 + */ + private static final class CapturingStubCallback implements DifyCallbackService { + final CountDownLatch latch; + final Map byTrace; + final long runStartMs; + + CapturingStubCallback(int n, Map byTrace, long runStartMs) { + this.latch = new CountDownLatch(n); + this.byTrace = byTrace; + this.runStartMs = runStartMs; + } + + @Override + public Boolean handleCallback(DifyCallbackReqVO reqVO) { + // 只捕获 + 归位 + 倒数:不触任何 DB/事务(与真 DifyCallbackServiceImpl 的三表写链解耦)。 + String traceId = reqVO.getTraceId(); + BriefResult r = (traceId == null) ? null : byTrace.get(traceId); + if (r != null) { + r.status = reqVO.getStatus(); + r.engineBundleLen = reqVO.getEngineBundle() == null ? 0 : reqVO.getEngineBundle().length(); + r.failureReason = reqVO.getFailureReason() == null ? "" : reqVO.getFailureReason(); + r.callbackAtMs = System.currentTimeMillis() - runStartMs; + System.out.println("[callback] #" + r.idx + " traceId=" + traceId + + " status=" + r.status + " engineBundleLen=" + r.engineBundleLen + + " reason=" + r.failureReason + " @+" + (r.callbackAtMs / 1000) + "s"); + } else { + // 不应发生(traceId 必在 map 内):留痕,仍倒数避免 latch 永挂。 + System.out.println("[callback] 未知 traceId=" + traceId + "(不在 map 内,忽略归位仍倒数)"); + } + latch.countDown(); + return Boolean.TRUE; + } + } + + @Test + @EnabledIfSystemProperty(named = "saa.e2e", matches = "1", + disabledReason = "需 mini-desktop(node+esbuild+Chrome+端口 4320/9222 空)+ NEWAPI_KEY;无机/无 key 时跳过避免误判 fail;开关 -Dsaa.e2e=1") + void saa_full_graph_e2e_success_rate() throws Exception { + // ── 0) 前置就位:NEWAPI_KEY(密钥经环境变量,绝不入库)+ game-runtime harness 依赖 ── + String apiKey = System.getenv("NEWAPI_KEY"); + assertNotNull(apiKey, "NEWAPI_KEY 未设(密钥须经环境变量注入,绝不入库)"); + assertTrue(!apiKey.isBlank(), "NEWAPI_KEY 为空"); + Path runtimeRoot = Paths.get(GAME_RUNTIME_DIR); + assertTrue(Files.isDirectory(runtimeRoot), "GAME_RUNTIME_DIR 不存在:" + runtimeRoot); + assertTrue(Files.exists(runtimeRoot.resolve("games/_wg1-gen/_shared/serve-and-play.sh")), + "缺 serve-and-play.sh(九门 harness 子进程地板):" + runtimeRoot); + 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); + + // ── 1) 构造真 AigcExecutorProperties(@Data 即 setter 全有)── + // checkpoint 关 + DataSource 传 null → 零 DB 依赖;dispatcher=saa 走进程内全图派发。 + AigcExecutorProperties props = new AigcExecutorProperties(); + props.setLlmBase(LLM_BASE); // SAA 内部 stripV1Suffix 自剥 /v1 + props.setApiKey(apiKey); // 经环境变量注入 + props.setDispatcher("saa"); // 走进程内 SAA 全图派发器(非 http worker) + props.setSaaGameRuntimeDir(GAME_RUNTIME_DIR); + props.setSaaMaxRepairs(5); // stage1 修复轮上限(对齐 Python studio.py 默认 5) + props.setSaaMaxPlayerRounds(1); // player 顾问轮上限 + props.setSaaStage2ExtraRepairs(3); // 救场阶梯 stage2 额外修复轮(5+3 阶梯) + props.setSaaCheckpointEnabled(false); // 关 checkpoint → 不依赖 DataSource,零 DB + + // ── 2) 结果容器 + stub 回调(共享 latch(N) + byTrace 精确归位)+ 派发器(5 参,sourceProjectApi=null=create 路)── + long runStart = System.currentTimeMillis(); + long runStamp = runStart; + List results = new ArrayList<>(); + Map byTrace = new ConcurrentHashMap<>(); + for (int i = 0; i < briefs.size(); i++) { + // traceId 唯一串:图内据此派生落盘目录名 saa-,回调据此精确归位。 + String traceId = "saa-e2e-" + runStamp + "-" + (i + 1); + BriefResult r = new BriefResult(i + 1, briefs.get(i), traceId); + results.add(r); + byTrace.put(traceId, r); + } + CapturingStubCallback stub = new CapturingStubCallback(briefs.size(), byTrace, runStart); + // 直接 new(非 Spring 装配),@PostConstruct warmUpGraphEagerly 不会被调用——dispatch 内部 ensureGraph + // 懒构造图,首条 dispatch 即触发构造,行为等价(仅少了启动期预热)。 + SaaGraphDispatcher dispatcher = new SaaGraphDispatcher( + props, stub, /*dataSource*/ null, ObservationRegistry.NOOP, /*sourceProjectApi*/ null); + + // ── 3) 一次性投递全部 N 条(每次 dispatch 提交后台单线程即返回;后台串行逐条跑全图)── + System.out.println(); + System.out.println("======== SAA 真·全图 e2e 投递 " + briefs.size() + " 条(后台串行跑)========"); + for (BriefResult r : results) { + Map job = new HashMap<>(); + job.put("brief", r.brief); + job.put("traceId", r.traceId); + job.put("templateId", "generic"); // create job 契约:仅 brief/traceId/templateId(modify 字段全缺=create) + try { + r.dispatched = dispatcher.dispatch(job); + } catch (Exception e) { + r.dispatched = false; + r.status = "(dispatch异常:" + e.getClass().getSimpleName() + ")"; + } + System.out.println("[dispatch] #" + r.idx + " traceId=" + r.traceId + + " -> " + (r.dispatched ? "投递成功" : "投递失败") + " brief=" + r.brief); + if (!r.dispatched && "(未回调)".equals(r.status)) { + // 投递前置失败(game-runtime 缺/图构造失败/入参非法):本条不会有回调,预先倒数补位避免 latch 永挂。 + r.status = "(投递失败)"; + stub.latch.countDown(); + } + } + + // ── 4) 等全部回调(共享 latch(N));总超时 = perBriefBudget × N(串行执行)。到点 best-effort 收尾 ── + long totalTimeoutSec = PER_BRIEF_BUDGET_SECONDS * briefs.size(); + System.out.println("======== 等待全部回调(总超时 " + totalTimeoutSec + "s = " + + PER_BRIEF_BUDGET_SECONDS + "s/条 × " + briefs.size() + " 条,后台串行)========"); + boolean allDone = stub.latch.await(totalTimeoutSec, TimeUnit.SECONDS); + long totalWallMs = System.currentTimeMillis() - runStart; + if (!allDone) { + System.out.println("[warn] 总超时 " + totalTimeoutSec + "s 到点仍有条目未回调(best-effort 收尾,未回调条计「(未回调)」)"); + } + + // ── 5) 失败/未回调路补读盘诊断(verdict.json/giveup-dump.json)── + for (BriefResult r : results) { + if (!r.succeeded() && r.dispatched) { + r.failDetail = readFailDetail(runtimeRoot, r.traceId); + } + } + + // ── 6) 汇总:成功率 X/N + 逐条证据 ── + long succeeded = results.stream().filter(BriefResult::succeeded).count(); + System.out.println(); + System.out.println("################ SAA 真·全图 e2e 汇总 ################"); + System.out.println("game-runtime = " + GAME_RUNTIME_DIR); + System.out.println("new-api base = " + LLM_BASE + "(SAA 内部剥 /v1)"); + System.out.println("总墙钟 = " + (totalWallMs / 1000) + "s(后台串行跑 " + briefs.size() + " 条)"); + System.out.println("成功率 = " + succeeded + "/" + briefs.size() + + " (" + String.format("%.1f", 100.0 * succeeded / briefs.size()) + "%)"); + System.out.println("------------------------------------------------------"); + for (BriefResult r : results) { + System.out.println(String.format( + "#%d status=%-26s bundleLen=%-7d callback@+%-5s reason=%s", + r.idx, r.status, r.engineBundleLen, + (r.callbackAtMs > 0 ? (r.callbackAtMs / 1000) + "s" : "-"), + r.succeeded() ? "-" : (r.failureReason.isEmpty() ? "(无)" : r.failureReason))); + System.out.println(" brief: " + r.brief); + if (!r.succeeded() && !r.failDetail.isBlank()) { + System.out.println(" fail : " + r.failDetail); + } + } + System.out.println("######################################################"); + + // ── 7) 存在性断言(首证):至少 1 条真全图 e2e 跑通;成功率作证据,不硬断 80%(留人判)── + assertTrue(succeeded >= 1, + "至少 1 条 SAA 真全图 e2e 应跑通(实得 " + succeeded + "/" + briefs.size() + + ");若全 0:检查 new-api 可达/九门 harness/端口 4320-9222 占用/图终态——见上方逐条 failDetail"); + } + + /** + * 失败诊断:从图落盘目录读 verdict.json(哪门未过)/ giveup-dump.json(救场耗尽真因)摘要。 + * 落盘目录名 = {@code saa-}(与 {@link SaaGraphDispatcher} buildInputs 一致,sanitize 把非 [A-Za-z0-9_-] 转 -)。 + * best-effort:读不到/解析失败返空串(不抛、不连坐主流程)。 + * + * @param runtimeRoot game-runtime 根 + * @param traceId 本条 brief 的 traceId + * @return 失败真因摘要(verdict 未过门概览 + giveup 末次失败),无则空串 + */ + private static String readFailDetail(Path runtimeRoot, String traceId) { + try { + // dispatcher 落盘目录名 = "saa-" + sanitize(traceId);sanitize 把非法字符转 -(traceId 已合法,恒等)。 + String gameDir = "saa-" + traceId.replaceAll("[^A-Za-z0-9_-]", "-"); + Path evidenceDir = runtimeRoot.resolve("games/_wg1-gen").resolve(gameDir).resolve("evidence"); + StringBuilder sb = new StringBuilder(); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + + Path verdict = evidenceDir.resolve("verdict.json"); + if (Files.exists(verdict)) { + var v = mapper.readTree(verdict.toFile()); + sb.append("verdict.pass=").append(v.path("pass").asBoolean(false)); + var guards = v.path("guards"); + if (guards.isObject()) { + sb.append(" 未过门:["); + var it = guards.fieldNames(); + boolean first = true; + while (it.hasNext()) { + String g = it.next(); + if (!guards.path(g).path("pass").asBoolean(false)) { // 只列未过的门(聚焦真因) + if (!first) sb.append(","); + sb.append(g); + first = false; + } + } + sb.append("]"); + } + } else { + sb.append("(无 verdict.json,可能 build 前即失败或图未到 play)"); + } + + Path giveup = evidenceDir.resolve("giveup-dump.json"); + if (Files.exists(giveup)) { + var g = mapper.readTree(giveup.toFile()); + sb.append(" giveup{modelTier=").append(g.path("modelTier").asText("?")) + .append(", failCount=").append(g.path("failCount").asText("?")) + .append(", lastStageFail=").append(g.path("lastStageFail").asText("?")).append("}"); + } + return sb.toString(); + } catch (Exception e) { + return "(读 failDetail 异常:" + e.getMessage() + ")"; + } + } +}