test(aigc): SaaFullGraphE2eTest — SAA 真全图 e2e 首证门控测试(U7)

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) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-18 00:40:14 +00:00
parent 679afa1c65
commit bf99662c3f

View File

@ -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 集成顶石:<b>首次证明 SAA 真·全图生成 e2e 跑通并测成功率</b>。
*
* <p><b>验什么(与既有 SAA 测试的区别)</b>:
* <ul>
* <li>{@link SaaNewApiGenerateSmokeTest} 只验「SAA→new-api→出工厂源码」一条窄链(无 build/play harness);</li>
* <li>{@link SaaHarnessDriveTest} 只验「金样 factory 驱动 build+九门 harness」(不接 LLM、不接全图、不接 job 契约);</li>
* <li><b>本测</b>=经 {@link SaaGraphDispatcher#dispatch} 真入口跑 {@link SaaStudioGraph} <b>16 节点全图</b>
* (render→classify→design→generate→validate→scaffold→asset→build→play→player→nreview→…→emit/giveup)
* + <b>真 LLM</b>(五角色经 new-api 网关)+ <b>真九门 harness</b>(build/play 子进程,串行真玩门 4320/9222)
* + <b>真出口</b>({@link DifyCallbackService#handleCallback} 据图终态组 reqVO)。<b>这一整条 e2e 从未跑过,本测首证。</b></li>
* </ul>
*
* <p><b>关键:dispatch 是「异步 + 单线程执行器 + Semaphore(1) 真玩门」=多 job 严格串行后台执行</b>。
* 故本测<b>先一次性投递全部 N 条</b>(每次 dispatch 提交后台即返回 true),<b>再用一把共享 {@link CountDownLatch}(N)
* 等全部回调</b>;stub 回调按 {@code reqVO.getTraceId()} 把每条结果<b>精确归位</b>到并发 map(不会跨条错配,
* 这是修掉「每条 arm 新 latch + 240s 超时」旧设计致命缺陷的关键——旧设计下后台串行跑、回调命中的是当时
* 恰好 arm 的那条 latch,归因全错)。落库由 stub 整体旁路(不触三表写链)。
*
* <p><b>纪律(纯测试·零 prod 改动)</b>:
* <ol>
* <li>门控 {@code -Dsaa.e2e=1}(无机/无 key 时不跑,避免误判 fail);</li>
* <li>{@code saaCheckpointEnabled=false} + DataSource 传 null → <b>零 DB 依赖</b>;</li>
* <li>N 默认 5,可 {@code -Dsaa.e2e.n=K} 覆盖(串行单条含修复轮约 5~15 分钟,N=5 需约 30~75 分钟,按 mvn timeout 量裁);</li>
* <li>等待<b>总</b>超时 = {@code perBriefBudget(默认 900s) × N},可 {@code -Dsaa.e2e.perBriefSec} 调;到点仍未集齐则
* best-effort 收尾(已回调的照常计,未回调的计「(未回调)」),<b>不一条卡死全挂</b>;</li>
* <li>成功率作证据打印,<b>不硬断 80%</b>——存在性断言放宽到「至少 1 条真全图 e2e 跑通」(首证存在性,成功率留人判)。</li>
* </ol>
*
* <p><b>运行(mini-desktop,game-runtime 有 node+esbuild+Chrome、端口 4320/9222 空)</b>:
* <pre>
* 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
* </pre>
*
* @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<String> 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()} 把结果<b>精确归位</b>到 byTrace map + 倒数共享 latch,
* <b>不写 DB</b>(替掉真回调的三表写链)。后台串行回调,无并发竞争;用 ConcurrentHashMap 保险。
*/
private static final class CapturingStubCallback implements DifyCallbackService {
final CountDownLatch latch;
final Map<String, BriefResult> byTrace;
final long runStartMs;
CapturingStubCallback(int n, Map<String, BriefResult> 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<String> 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<BriefResult> results = new ArrayList<>();
Map<String, BriefResult> byTrace = new ConcurrentHashMap<>();
for (int i = 0; i < briefs.size(); i++) {
// traceId 唯一串:图内据此派生落盘目录名 saa-<sanitize(traceId)>,回调据此精确归位。
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<String, Object> 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-<sanitize(traceId)>}(与 {@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() + ")";
}
}
}