diff --git a/.eslintrc.cjs b/.eslintrc.cjs index ae8275e7c..d7c198f3e 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -219,6 +219,7 @@ module.exports = { '!src/services/frontendRuntimeConfigService.test.ts', '!src/services/gameDistributionClient.ts', '!src/services/gameDistributionClient.test.ts', + '!src/services/gamePlayClientId.ts', '!src/services/sseStream.ts', '!src/services/sseStream.test.ts', 'src/AdventurePanel.tsx', diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 79d145ae3..0d3ec33c6 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -19,7 +19,8 @@ "bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs", "llm-status": "node scripts/run-cli-with-config.mjs --llm-status", "config": "node scripts/game-creator-config-wizard.mjs", - "typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs" + "check:tests:types": "tsc -p tsconfig.tests.json --noEmit", + "typecheck": "npm run check:tests:types && tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.3.285", diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index bfb958806..d35efd97a 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -18,6 +18,7 @@ import { import { AGC_DEFAULT_CHANNEL, resolveChannelInstallIdentity, + resolveChannelMainBinaryName, resolveReleaseChannel, } from './channel-identity.mjs'; import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; @@ -38,6 +39,9 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url)); const devChannelInstallerHooks = fileURLToPath( new URL('../src-tauri/windows/installer-hooks.nsh', import.meta.url), ); +const releaseChannelInstallerHooks = fileURLToPath( + new URL('../src-tauri/windows/release-installer-hooks.nsh', import.meta.url), +); // Git 命令用于读取 release revision,必须在仓库根执行,不能在应用目录里执行。 const repoRoot = path.resolve(appRoot, '..', '..'); const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; @@ -423,6 +427,7 @@ export function createChannelConfig( const config = { productName, identifier, + mainBinaryName: resolveChannelMainBinaryName(channel), app: { windows: [{ ...baseClientWindow, title: productName }], }, @@ -432,10 +437,20 @@ export function createChannelConfig( }, }, }; - // 只有 dev 渠道改过展示名:它的包需要自带旧身份迁移;其它渠道不能拿这份旧名表去删安装。 - if (channel === AGC_DEFAULT_CHANNEL && target.includes('windows')) + // dev 渠道迁移改名前的旧身份;release 渠道迁移曾使用过「陶泥儿 Release」的安装与快捷方式。 + if ( + target.includes('windows') && + (channel === AGC_DEFAULT_CHANNEL || channel === 'release') + ) config.bundle = { - windows: { nsis: { installerHooks: devChannelInstallerHooks } }, + windows: { + nsis: { + installerHooks: + channel === AGC_DEFAULT_CHANNEL + ? devChannelInstallerHooks + : releaseChannelInstallerHooks, + }, + }, }; return config; } diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 4b6fc20cc..a77c64b5b 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -35,8 +35,10 @@ import { } from './build-release.mjs'; import { AGC_APP_IDENTIFIER, + AGC_MAIN_BINARY_NAME, AGC_PRODUCT_NAME, resolveChannelInstallIdentity, + resolveChannelMainBinaryName, } from './channel-identity.mjs'; import { supportedHostTarget } from './prepare-bundled-resources.mjs'; @@ -188,6 +190,7 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), { productName: `${AGC_PRODUCT_NAME}开发版`, identifier: AGC_APP_IDENTIFIER, + mainBinaryName: AGC_MAIN_BINARY_NAME, app: { windows: [ { @@ -236,6 +239,15 @@ test('channel install identity isolates co-installed builds and keeps the defaul productName: '陶泥儿 Beta-2', identifier: `${AGC_APP_IDENTIFIER}.beta-2`, }); + assert.equal(resolveChannelMainBinaryName('dev'), AGC_MAIN_BINARY_NAME); + assert.equal( + resolveChannelMainBinaryName('release'), + `${AGC_MAIN_BINARY_NAME}-release`, + ); + assert.equal( + resolveChannelMainBinaryName('beta-2'), + `${AGC_MAIN_BINARY_NAME}-beta-2`, + ); // release 与其它非默认渠道通过独立 identifier 隔离运行身份与数据目录; // 自定义渠道另外保留可区分的产品名。 @@ -269,6 +281,7 @@ test('channel install identity is baked into the same build-time config as the e const config = createChannelConfig('release', windowsTarget); assert.equal(config.productName, AGC_PRODUCT_NAME); assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`); + assert.equal(config.mainBinaryName, `${AGC_MAIN_BINARY_NAME}-release`); assert.match( config.plugins.updater.endpoints[0], /\/release-win\/latest\.json$/u, @@ -280,7 +293,7 @@ test('channel install identity is baked into the same build-time config as the e * dev 渠道展示名改过两次(Genarrative AI Game Creator → 陶泥儿 → 陶泥儿开发版),而更新路径 * (/UPDATE)不会重建快捷方式:改名后的包必须自带旧身份迁移,否则桌面快捷方式会一直指向旧安装。 */ -test('dev 渠道的 Windows 包注入改名迁移钩子,其它渠道与目标不注入', () => { +test('渠道 Windows 包注入各自迁移钩子,且主进程文件名按渠道隔离', () => { withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { const devWindows = createChannelConfig('dev', windowsTarget); const hooksPath = devWindows.bundle.windows.nsis.installerHooks; @@ -296,10 +309,27 @@ test('dev 渠道的 Windows 包注入改名迁移钩子,其它渠道与目标 `迁移钩子缺少旧身份:${legacyIdentity}`, ); } + assert.match( + hooksSource, + /AGC_RELEASE_MAIN_BINARY\s+"genarrative-ai-game-creator-shell-release"/u, + ); - // 改名只发生在 dev:其它渠道注入会拿 dev 的旧名表去删别人的安装。 + const releaseWindows = createChannelConfig('release', windowsTarget); + const releaseHooksPath = releaseWindows.bundle.windows.nsis.installerHooks; + assert.ok(releaseHooksPath); + assert.equal(existsSync(releaseHooksPath), true); + assert.match(readFileSync(releaseHooksPath, 'utf8'), /陶泥儿 Release/u); assert.equal( - createChannelConfig('release', windowsTarget).bundle, + releaseWindows.mainBinaryName, + `${AGC_MAIN_BINARY_NAME}-release`, + ); + assert.notEqual( + devWindows.mainBinaryName, + releaseWindows.mainBinaryName, + 'dev 与 release 的 NSIS 进程匹配名必须不同', + ); + assert.equal( + createChannelConfig('beta-2', windowsTarget).bundle, undefined, ); // macOS 包没有 NSIS 安装器,不能带上 Windows 钩子。 @@ -395,6 +425,44 @@ test('安装钩子的可执行逻辑必须全在 !macro 内,顶层不得引用 ); }); +test('release 安装钩子迁移旧 Release 身份且保持编译期位置纪律', () => { + const hooksPath = createChannelConfig('release', windowsTarget).bundle.windows + .nsis.installerHooks; + const hooksSource = readFileSync(hooksPath, 'utf8'); + assert.deepEqual( + readFileSync(hooksPath).subarray(0, 3), + Buffer.from([0xef, 0xbb, 0xbf]), + 'NSIS 中文钩子必须保留 UTF-8 BOM', + ); + assert.match(hooksSource, /陶泥儿 Release/u); + assert.match(hooksSource, /AGC_RELEASE_LEGACY_BINARY/u); + assert.match(hooksSource, /NSIS_HOOK_PREINSTALL/u); + assert.match(hooksSource, /NSIS_HOOK_POSTINSTALL/u); + let macroDepth = 0; + const offenders = []; + for (const [index, rawLine] of hooksSource.split('\n').entries()) { + const line = rawLine.trim(); + if (line === '' || line.startsWith(';')) continue; + if (/^!macroend\b/u.test(line)) { + macroDepth = Math.max(0, macroDepth - 1); + continue; + } + if (/^!macro\b/u.test(line)) { + macroDepth += 1; + continue; + } + if (macroDepth > 0) continue; + if ( + line.includes('::') || + /^(?:Function(?:End)?|Return|Delete|ReadRegStr|StrCpy|CreateShortCut)\b/u.test( + line, + ) + ) + offenders.push(`第 ${index + 1} 行出现顶层可执行语句:${line}`); + } + assert.deepEqual(offenders, []); +}); + /** * RFC 7386(tauri-utils 用 `json_patch::merge`)语义:对象递归合并,数组整体替换。 * 这里按同样语义复现 Tauri CLI 的 `--config` 合并,用来守住"渠道配置不得丢窗口契约"。 diff --git a/apps/ai-game-creator-shell/scripts/channel-identity.mjs b/apps/ai-game-creator-shell/scripts/channel-identity.mjs index 90ed39529..9af13d7e1 100644 --- a/apps/ai-game-creator-shell/scripts/channel-identity.mjs +++ b/apps/ai-game-creator-shell/scripts/channel-identity.mjs @@ -16,6 +16,12 @@ export const AGC_DEFAULT_CHANNEL = 'dev'; export const AGC_PRODUCT_NAME = '陶泥儿'; export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator'; +/** + * Tauri/NSIS 默认按主可执行文件名查找并结束进程。 + * dev 保留历史文件名以维持既有更新链;其它渠道使用独立文件名, + * 这样更新一个渠道时不会把另一个渠道的进程一起结束。 + */ +export const AGC_MAIN_BINARY_NAME = 'genarrative-ai-game-creator-shell'; const reservedChannelNames = new Set([ 'win', @@ -75,3 +81,11 @@ export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) { : `${AGC_APP_IDENTIFIER}.${channel}`, }); } + +/** 渠道对应的主可执行文件名(不含平台扩展名)。 */ +export function resolveChannelMainBinaryName(channel = AGC_DEFAULT_CHANNEL) { + validateReleaseChannel(channel); + return channel === AGC_DEFAULT_CHANNEL + ? AGC_MAIN_BINARY_NAME + : `${AGC_MAIN_BINARY_NAME}-${channel}`; +} diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 93a003285..9272f77ac 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -25,7 +25,9 @@ execFileSync( import { AGC_APP_IDENTIFIER, + AGC_MAIN_BINARY_NAME, resolveChannelInstallIdentity, + resolveChannelMainBinaryName, } from './channel-identity.mjs'; import { askHidden, @@ -1300,9 +1302,14 @@ if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) { throw new Error('AI game creator shell identifier drifted'); } +if (tauriConfig.mainBinaryName !== AGC_MAIN_BINARY_NAME) { + throw new Error('AI game creator shell mainBinaryName drifted'); +} + if ( tauriConfig.productName !== defaultChannelIdentity.productName || - tauriConfig.identifier !== defaultChannelIdentity.identifier + tauriConfig.identifier !== defaultChannelIdentity.identifier || + tauriConfig.mainBinaryName !== resolveChannelMainBinaryName('dev') ) { throw new Error( 'AI game creator shell baseline config must match the default channel identity', @@ -1315,6 +1322,8 @@ for (const channel of ['release', 'beta-2']) { if ( identity.productName === defaultChannelIdentity.productName || identity.identifier === defaultChannelIdentity.identifier || + resolveChannelMainBinaryName(channel) === + resolveChannelMainBinaryName('dev') || !identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`) ) { throw new Error(`channel install identity not isolated: ${channel}`); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs index bafdb8b7f..429c97240 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs @@ -28,7 +28,22 @@ pub(crate) const DIRECT_ACTIVE_TURNS_CHANGED_EVENT: &str = "game-creator-direct-active-turns-changed"; static DIRECT_ACTIVE_TURNS_EVENT_REVISION: AtomicU64 = AtomicU64::new(0); #[cfg(test)] -static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: AtomicU64 = AtomicU64::new(0); +thread_local! { + /// 只统计**当前线程**发出的通知。`--test-threads=1` 只串行测试线程,宿主 + /// `tauri::async_runtime` 的后台回合仍在自己的工作线程上跑(放行任务随 + /// `TurnReservation::drop` 起整轮),并会在任意时刻广播「运行中的项目」变了。断言要观测的 + /// 是本测试自己触发的通知,不该被别的后台广播串台。 + /// + /// 这里必须留在测试线程作用域内:退役 `runtime_driver` 时这条口径曾被降级回进程级 + /// `AtomicU64`,于是 `agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` + /// 又回到偶发(同一片内前一个用例留下的后台回合广播进采样窗口)。 + /// + /// 代价:计数改成线程作用域后,**别的线程**上的重复 / 丢失通知不再被这条用例覆盖(那是 + /// 宿主后台回合的行为,本身就不该由单线程断言口径表达);要覆盖跨线程序,应另加用例 + /// 观察回合身份/序列号,而不是把计数器退回进程级。 + static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16; @@ -54,7 +69,7 @@ pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHa pub(crate) fn emit_direct_active_turns_changed() { let revision = DIRECT_ACTIVE_TURNS_EVENT_REVISION.fetch_add(1, Ordering::AcqRel) + 1; #[cfg(test)] - DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.fetch_add(1, Ordering::AcqRel); + let _ = DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.try_with(|count| count.set(count.get() + 1)); let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; @@ -66,7 +81,7 @@ pub(crate) fn emit_direct_active_turns_changed() { #[cfg(test)] pub(crate) fn direct_active_turns_event_test_count() -> u64 { - DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.load(Ordering::Acquire) + DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.with(std::cell::Cell::get) } pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, message: &str) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs index c96ef974c..9c47ae81c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs @@ -630,12 +630,20 @@ fn process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup() { let root = directory.path(); init_local_game_project_at(root, "graceful-process-project", "Graceful Process Project") .expect("initialize project"); + // leader 输出 READY 后 **等** 同组后台子进程,不自己先退出。terminate 必须先送到 trampoline + // 的 target group、再让组内后代把延迟清理跑完,这条断言才有确定性:leader 一旦先自然退出, + // trampoline 的 800ms 宽限就开始计时,客户端只要在那之后才发出 terminate(CI 高负载下调度 + // 完全可能 >800ms),会话就会先以 `exited` 收口,客户端再 terminate 只能读到既成事实。 + // 后台子进程仍留在同一进程组里、仍靠 TERM 触发延迟 400ms 的 marker 清理,语义不变。 + // 注意:这条用例的确定性靠的是「trap 里 400ms 清理 < 800ms 宽限」的时间余量(实测 0.41s), + // 不是真正的同步原语;若以后清理耗时逼近或超过宽限,应把 marker 拆成「收到 TERM 即时落盘 + + // 延迟内容」两步,或让宽限可注入,而不是放宽断言。 let spec = resolve_project_command_spec_at( root, "bash", &[ "-lc".to_string(), - "(trap 'sleep 0.4; printf done > graceful-marker.txt; exit 0' TERM; while :; do sleep 1; done) & printf 'READY\\n'; exit 0".to_string(), + "(trap 'sleep 0.4; printf done > graceful-marker.txt; exit 0' TERM; while :; do sleep 1; done) & printf 'READY\\n'; wait".to_string(), ], ".", 30, diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index cf24da0c5..63cd157e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,6 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "陶泥儿开发版", + "mainBinaryName": "genarrative-ai-game-creator-shell", "version": "0.1.67", "identifier": "world.genarrative.ai-game-creator", "build": { diff --git a/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh b/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh index a5dadc3bd..16b2c3a80 100644 --- a/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh +++ b/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh @@ -30,16 +30,19 @@ ; 旧展示名表:以后再改展示名,必须在这里追加旧名,否则升级后旧快捷方式会继续指向旧安装。 !define AGC_LEGACY_IDENTITY_1 "陶泥儿" !define AGC_LEGACY_IDENTITY_2 "Genarrative AI Game Creator" +!define AGC_RELEASE_MAIN_BINARY "genarrative-ai-game-creator-shell-release" Var AgcLegacyIdentity Var AgcLegacyInstallDir Var AgcMigratedLegacyDesktopShortcut +Var AgcLegacyOwnedByRelease ; 清理一个旧身份。调用方先把旧展示名写进 $AgcLegacyIdentity,再 !insertmacro 本宏;宏不返回错误。 ; 只在「旧展示名目录下确实存在我们的主程序」时才动手,避免误删同名目录; ; 当前安装目录 $INSTDIR 永不进入删除路径。 !macro AgcMigrateLegacyIdentity StrCpy $AgcLegacyInstallDir "" + StrCpy $AgcLegacyOwnedByRelease 0 ${If} ${FileExists} "$LOCALAPPDATA\$AgcLegacyIdentity\${MAINBINARYNAME}.exe" StrCpy $AgcLegacyInstallDir "$LOCALAPPDATA\$AgcLegacyIdentity" @@ -49,6 +52,15 @@ Var AgcMigratedLegacyDesktopShortcut ${If} $AgcLegacyInstallDir != "" ${AndIf} $AgcLegacyInstallDir != $INSTDIR + ; release 新包使用独立主程序名;同一旧展示名目录若已有该文件,不能当作旧 dev 清理。 + ${If} ${FileExists} "$AgcLegacyInstallDir\${AGC_RELEASE_MAIN_BINARY}.exe" + StrCpy $AgcLegacyOwnedByRelease 1 + ${EndIf} + ${EndIf} + + ${If} $AgcLegacyInstallDir != "" + ${AndIf} $AgcLegacyInstallDir != $INSTDIR + ${AndIf} $AgcLegacyOwnedByRelease = 0 ; 桌面图标:只有目标正好指向旧目录的主程序时才删,避免误删同名的用户快捷方式。 !insertmacro IsShortcutTarget "$DESKTOP\$AgcLegacyIdentity.lnk" "$AgcLegacyInstallDir\${MAINBINARYNAME}.exe" Pop $0 diff --git a/apps/ai-game-creator-shell/src-tauri/windows/release-installer-hooks.nsh b/apps/ai-game-creator-shell/src-tauri/windows/release-installer-hooks.nsh new file mode 100644 index 000000000..f3b89735c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/windows/release-installer-hooks.nsh @@ -0,0 +1,82 @@ +; AGC release 渠道的 Windows 安装迁移钩子。 +; +; release 曾使用「陶泥儿 Release」作为 productName,后来统一为「陶泥儿」。 +; Tauri 的 NSIS 更新模式不会为改名后的身份重建快捷方式,也不会自动读取旧身份的安装目录, +; 所以这里把旧安装目录、旧快捷方式迁移到当前 release 身份。 +; +; release 渠道同时使用独立的 MAINBINARYNAME(genarrative-ai-game-creator-shell-release), +; NSIS 默认的按文件名查杀逻辑因此不会碰 dev 渠道的主进程。 +; +!define AGC_RELEASE_LEGACY_IDENTITY "陶泥儿 Release" +!define AGC_RELEASE_LEGACY_BINARY "genarrative-ai-game-creator-shell" + +Var AgcReleaseLegacyInstallDir +Var AgcReleaseMigratedDesktopShortcut + +; Tauri 模板会在 Section Install 开头先执行 SetOutPath,再展开 PREINSTALL 宏。 +; 此处仍可在复制文件前改写 $INSTDIR,让旧 release 安装原地升级。 +!macro NSIS_HOOK_PREINSTALL + StrCpy $AgcReleaseLegacyInstallDir "" + ReadRegStr $R0 SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AGC_RELEASE_LEGACY_IDENTITY}" "InstallLocation" + ; Tauri 将 InstallLocation 写成带引号的 REG_SZ;去掉包裹引号后再交给 FileExists。 + StrCpy $R1 $R0 1 + ${If} $R1 == '$\"' + StrCpy $R0 $R0 "" 1 + StrLen $R1 $R0 + IntOp $R1 $R1 - 1 + StrCpy $R0 $R0 $R1 + ${EndIf} + ${If} $R0 != "" + ${AndIf} ${FileExists} "$R0\${AGC_RELEASE_LEGACY_BINARY}.exe" + StrCpy $AgcReleaseLegacyInstallDir $R0 + StrCpy $INSTDIR $R0 + ${EndIf} +!macroend + +!macro NSIS_HOOK_POSTINSTALL + ; 更新模式下模板不会重建快捷方式。先把旧名称和旧主程序名指向当前 release。 + !insertmacro IsShortcutTarget "$DESKTOP\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe" + Pop $0 + ${If} $0 = 1 + StrCpy $AgcReleaseMigratedDesktopShortcut 1 + !insertmacro UnpinShortcut "$DESKTOP\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" + Delete "$DESKTOP\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" + ${EndIf} + + !insertmacro IsShortcutTarget "$SMPROGRAMS\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" + Delete "$SMPROGRAMS\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" + ${EndIf} + + ; 用户可能早已把快捷方式改成当前名称,但仍指向旧主程序名。 + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + ${EndIf} + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + ${EndIf} + + ; 开始菜单图标始终存在;桌面图标只在迁移前存在时补建,保持用户选择。 + StrCpy $R0 "$INSTDIR\${MAINBINARYNAME}.exe" + ${IfNot} ${FileExists} "$SMPROGRAMS\${PRODUCTNAME}.lnk" + CreateShortCut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$R0" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk" + ${EndIf} + ${If} $AgcReleaseMigratedDesktopShortcut = 1 + CreateShortCut "$DESKTOP\${PRODUCTNAME}.lnk" "$R0" + !insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk" + ${EndIf} + + ; 新 release 已接管旧安装目录后,删除旧主程序,避免 dev 的迁移钩子把该目录误判为旧 dev。 + ${If} $AgcReleaseLegacyInstallDir != "" + Delete "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe" + DeleteRegKey SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AGC_RELEASE_LEGACY_IDENTITY}" + DeleteRegKey HKCU "Software\genarrative\${AGC_RELEASE_LEGACY_IDENTITY}" + ${EndIf} +!macroend diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 1bd48562a..4aee35d18 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -71,6 +71,7 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from './features/project-summary/projectSummary'; +import { insertChatReferences } from './features/project-workspace/activeChatComposer'; import { importDesignFiles } from './features/project-workspace/importDesignFiles'; import { needsInitializedChatProject, @@ -78,10 +79,13 @@ import { } from './features/project-workspace/projectCommandPolicy'; import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput'; import { + type ChatReference, directCodexContentToLegacyContentDto, hasMeaningfulDirectCodexContent, RESOURCE_REFERENCE_INSERT_EVENT, + RESOURCE_REFERENCE_INSERT_MANY_EVENT, type ResourceReferenceInsertEventDetail, + type ResourceReferenceInsertManyEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; import { readGamePublishAvailability } from './services/gameDistributionPublish'; @@ -1273,22 +1277,54 @@ export function App({ } useEffect(() => { + /* + 画布引用只有这一处消费者:单条「引用」按钮与拖拽批量引用都从这里落进草稿。 + 落到哪份输入区由「活跃聊天输入区」注册表回答——普通项目挂 DirectProject、 + 策划链路挂策划面,事件本身不携带这个判断。 + + 插入失败(此刻没有可用的输入区——没挂载或已卸载)不再静默:dev 下留一行线索, + 否则用户看到的又是一次「点了没反应」。空批次不是失败,它没有要插的东西, + 也就不能把原因指到输入区上。 + */ + const insertReferences = (references: readonly ChatReference[]) => { + if (references.length === 0) return; + if (insertChatReferences(references)) return; + if (import.meta.env.DEV) { + console.warn( + '[resource-reference] 引用没有落进草稿:没有可用的聊天输入区(未挂载或已卸载)', + ); + } + }; const handleResourceReferenceInsert = (event: Event) => { const detail = (event as CustomEvent) .detail; if (!detail?.reference) return; - chatComposerRef.current?.insertReferences([detail.reference]); - chatComposerRef.current?.focus(); + insertReferences([detail.reference]); + }; + const handleResourceReferenceInsertMany = (event: Event) => { + const detail = ( + event as CustomEvent + ).detail; + insertReferences(detail?.references ?? []); }; window.addEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); - return () => + window.addEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + return () => { window.removeEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); + window.removeEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + }; }, []); /** diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts new file mode 100644 index 000000000..cc67b7a7b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts @@ -0,0 +1,66 @@ +import type { ChatReference } from './resourceReferences'; + +/** + * 当前挂载的聊天输入区句柄:只保留「把引用插进草稿」这一件事。 + * + * `insertReferences` 回答**这一批有没有真的落进草稿**(输入区自己那份句柄可能还没挂上、 + * 或者已经被卸载),注册表按它决定成功还是失败,不让「注册表里有句柄」冒充「用户看得见 + * 的结果」。输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板 + * 内部用,不进这里——注册表只负责跨面板的引用插入。 + */ +export type ActiveChatComposerHandle = { + insertReferences: (references: ChatReference[]) => boolean; + focus: () => void; +}; + +/** + * 同一时刻只可能有一个聊天输入区挂载:普通项目走 DirectProject,立项策划走策划面, + * 两条链路互斥渲染(见 `App.tsx` 的 `directProjectMode`)。 + */ +let activeChatComposer: ActiveChatComposerHandle | null = null; + +/** + * 注册当前挂载的聊天输入区,返回注销函数。 + * + * 注销时按身份校验:新输入区已经接管、旧输入区才卸载时(切换项目、两条链路互换), + * 旧注销不能把新句柄一起清掉。 + * + * 两个输入区同时挂载属于调用方接线错误(本应互斥,见 `App.tsx` 的 `directProjectMode`): + * 后注册者会顶掉前者,引用会落进用户看不见的那份草稿。这里只留一条线索,不改运行时语义 + * ——注册表仍然按最后注册的那个工作,注销的身份校验也照旧兜住乱序卸载。 + */ +export function registerActiveChatComposer( + handle: ActiveChatComposerHandle, +): () => void { + if (activeChatComposer && activeChatComposer !== handle) { + if (import.meta.env.DEV) { + console.warn( + '[resource-reference] 检测到第二个聊天输入区注册:引用会插进最后注册的那一个', + ); + } + } + activeChatComposer = handle; + return () => { + if (activeChatComposer === handle) { + activeChatComposer = null; + } + }; +} + +/** + * 把一批引用插进当前挂载的聊天输入区,回答**有没有落进草稿**。 + * + * 三种情况都返回 `false`:空批次、此刻没有任何输入区挂载、注册表里的句柄已经插不进去 + * (它转发的那份输入区没挂上或已卸载)。这些都不能静默,由调用方(`App.tsx` 的事件监听) + * 决定怎么留痕或提示,注册表本身不吞;只有真的插进去了才把焦点交给输入区。 + */ +export function insertChatReferences( + references: readonly ChatReference[], +): boolean { + if (references.length === 0) return false; + const handle = activeChatComposer; + if (!handle) return false; + if (!handle.insertReferences([...references])) return false; + handle.focus(); + return true; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index f2d345d75..a429ada6a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -4,17 +4,22 @@ import { useEffect, useImperativeHandle, useMemo, + useRef, useState, } from 'react'; import { claimInitialTurnForPage } from '../../../app/initialTurnClaims'; import type { PendingUiConfirmation } from '../../../app/types'; import { projectNameFromPath } from '../../../features/agent-runtime'; +import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer'; import { userItemFromContent } from '../../../features/project-workspace/resourceReferences'; import { type ApprovalMode, approvalModeLabel } from '../approvalMode'; import { ApprovalModeDialog } from '../ApprovalModeDialog'; import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader'; -import { DirectProjectComposer } from './components/DirectProjectComposer/DirectProjectComposer'; +import { + DirectProjectComposer, + type DirectProjectComposerHandle, +} from './components/DirectProjectComposer/DirectProjectComposer'; import { DirectProjectConversation } from './components/DirectProjectConversation/DirectProjectConversation'; import { DirectProjectSettingsDialog } from './components/DirectProjectSettingsDialog/DirectProjectSettingsDialog'; import { @@ -103,6 +108,7 @@ export function DirectProjectChatView({ }: DirectProjectChatViewProps) { const { assets, projectId, refresh, versions } = useDirectProjectManifest(projectPath); + const composerHandleRef = useRef(null); const [runtimeNotice, setRuntimeNotice] = useState(''); const [settingsOpen, setSettingsOpen] = useState(false); const [approvalOpen, setApprovalOpen] = useState(false); @@ -212,6 +218,28 @@ export function DirectProjectChatView({ }, })); + /* + 画布的「引用」与拖拽批量引用是 window 事件,唯一消费者在 `App.tsx`;它只认注册表里 + **当前挂载**的输入区。普通项目固定渲染这里、策划链路渲染 `PlanningChatView`,两条链路 + 互斥,所以同一时刻注册表里只有一个句柄。 + + 注册的是一个**按 ref 转发**的句柄、且不依赖「输入区此刻已挂上」:注册只表达「这个聊天面 + 在用」,插入成功与否由转发那一刻的实际情况回答(`DirectProjectComposerHandle.insertReferences` + 返回 boolean)。这样挂载顺序、子组件重挂载都不会让注册表漏挂或指向死句柄。 + */ + useEffect( + () => + registerActiveChatComposer({ + insertReferences: (references) => { + const handle = composerHandleRef.current; + if (!handle) return false; + return handle.insertReferences(references); + }, + focus: () => composerHandleRef.current?.focus(), + }), + [], + ); + return (
) : null} Promise; + /** + * 输入区句柄出口:`DirectProjectChatView` 拿它注册「活跃聊天输入区」, + * 画布的「引用」/拖拽批量引用事件才能落进这份草稿。 + */ + ref?: Ref; }) { const composerRef = useRef(null); const composerRootRef = useRef(null); @@ -166,6 +189,24 @@ export function DirectProjectComposer({ }, [onUploadFiles], ); + /* + 对外句柄只做一层转发:内部输入区句柄会随编辑器重挂载换对象,这里按 ref 读最新值, + 句柄本身(注册表持有的那个)保持稳定。插入与聚焦都不自己实现,避免出现第二套草稿真相; + `insertReferences` 回答有没有真的递到输入区——注册表拿它区分「插进去了」和「这一步落空」。 + */ + useImperativeHandle( + ref, + () => ({ + insertReferences: (references: ChatReference[]) => { + const handle = composerRef.current; + if (!handle) return false; + handle.insertReferences([...references]); + return true; + }, + focus: () => composerRef.current?.focus(), + }), + [], + ); // 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管), // 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。 const submitButton = ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx index 7e35bc9a0..928d75d03 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx @@ -1,6 +1,6 @@ import { ArrowUp, FileUp, Loader2 } from 'lucide-react'; import type { FormEventHandler, RefObject, UIEventHandler } from 'react'; -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { AgentMessageContent } from '../../../../../../packages/shared/src/components/AgentMessageContent'; import type { @@ -15,6 +15,7 @@ import { projectNameFromPath, projectRuntimeVisibleError, } from '../../../features/agent-runtime'; +import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer'; import { ConversationModelSelect } from '../../../features/project-workspace/ConversationModelSelect'; import { attachmentReferenceProvider } from '../../../features/project-workspace/reference-source/attachmentReferenceProvider'; import { createResourceReferenceProvider } from '../../../features/project-workspace/reference-source/resourceReferenceProvider'; @@ -133,6 +134,23 @@ export function PlanningChatView({ onDesignRetry, }: PlanningChatViewProps) { const designFileInputRef = useRef(null); + /* + 策划输入盒也进「活跃聊天输入区」注册表:画布的引用事件消费者只有 `App.tsx` 一处, + 它不关心当前挂哪条链路。句柄按 ref 转发(`composerRef` 指向的是输入区自己那份 + 可变句柄),注册的那个包装对象因此永远读到最新值,并由它回答「这一批有没有真的插进去」。 + */ + useEffect(() => { + if (!composerRef) return; + return registerActiveChatComposer({ + insertReferences: (references) => { + const handle = composerRef.current; + if (!handle) return false; + handle.insertReferences([...references]); + return true; + }, + focus: () => composerRef.current?.focus(), + }); + }, [composerRef]); /* 策划输入盒的引用来源:**只注入资源与两个静默 provider**。 diff --git a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts index bc73e068c..9547282a9 100644 --- a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -11,8 +11,11 @@ import { import React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import type { ProjectResourceCanvasPosition } from '../../../packages/shared/src/contracts/gameCreationApp'; -import type { ProjectResourceCanvasCategory } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + isProjectResourceCanvasCategory, + type ProjectResourceCanvasCategory, + type ProjectResourceCanvasPosition, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import { RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, @@ -130,7 +133,11 @@ function OverlayHarness({ overlayRef?: React.Ref; }) { const sections = Array.from( - new Set(positions.map((position) => position.section)), + new Set( + positions + .map((position) => position.section) + .filter(isProjectResourceCanvasCategory), + ), ); return React.createElement( 'div', @@ -809,7 +816,7 @@ describe('ResourceDependencyOverlay', () => { it('keeps drag previews in plane-local coordinates', async () => { const getBoundingClientRect = vi .spyOn(HTMLElement.prototype, 'getBoundingClientRect') - .mockImplementation(function () { + .mockImplementation(function (this: HTMLElement) { const isPlane = this.hasAttribute('data-resource-section-plane'); return { x: isPlane ? 160 : 20, @@ -1046,7 +1053,9 @@ describe('ResourceDependencyOverlay', () => { .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4, ); - return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + return ( + channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722 + ); }; const lineLuminance = luminance('c45f20'); const canvasLuminance = luminance('fffdfa'); diff --git a/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx b/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx index 4ff959c69..49c1d78f9 100644 --- a/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx +++ b/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx @@ -2,13 +2,16 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { ResourceEditSurface } from '../src/view/project-development/ResourceEditSurface'; +import { + type ResourceEditSubmitInput, + ResourceEditSurface, +} from '../src/view/project-development/ResourceEditSurface'; import { fireEvent, render, screen, waitFor } from './appSurface/harness'; describe('统一资源编辑面板', () => { it('失败后锁定原请求字段并使用相同输入重试', async () => { const submit = vi - .fn<(input: { prompt: string; assetName: string }) => Promise>() + .fn<[input: ResourceEditSubmitInput], Promise>() .mockRejectedValueOnce(new Error('网络暂不可用')) .mockResolvedValueOnce(undefined); render( diff --git a/apps/ai-game-creator-shell/tests/accountHost.test.ts b/apps/ai-game-creator-shell/tests/accountHost.test.ts index 2952a9e14..8679d940b 100644 --- a/apps/ai-game-creator-shell/tests/accountHost.test.ts +++ b/apps/ai-game-creator-shell/tests/accountHost.test.ts @@ -14,9 +14,9 @@ const platformSession = vi.hoisted(() => ({ })); // 与仓库其它 App 内调用方同口径:通过 `window.__TAURI__.core.invoke` 解析原生桥。 -window.__TAURI__ = { +installTauriRuntime({ core: { invoke: (...args: unknown[]) => invoke(...args) }, -} as never; +}); vi.mock('../src/services/platformSession', () => ({ currentPlatformSessionGeneration: () => platformSession.generation, requestPlatformSessionRefresh: (...args: unknown[]) => @@ -32,6 +32,7 @@ import { getClientProfileWalletLedger, redeemClientProfileRewardCode, } from '../src/services/accountHost'; +import { installTauriRuntime } from './tauriRuntimeFake'; beforeEach(() => { invoke.mockReset(); diff --git a/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts new file mode 100644 index 000000000..759a376e3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + type ActiveChatComposerHandle, + insertChatReferences, + registerActiveChatComposer, +} from '../src/features/project-workspace/activeChatComposer'; +import type { ChatReference } from '../src/features/project-workspace/resourceReferences'; + +function resourceReference(resourceId: string): ChatReference { + return { + type: 'resource', + resourceId, + kind: 'character', + mediaType: 'image/png', + label: resourceId, + category: 'character', + tags: [], + source: 'resource-card', + }; +} + +/** 一个「插入总是成功」的输入区句柄替身;`inserted` 为 false 时模拟输入区已不在位。 */ +function composerHandle(inserted = true): { + handle: ActiveChatComposerHandle; + insertReferences: ReturnType; + focus: ReturnType; +} { + const insertReferences = vi.fn(() => inserted); + const focus = vi.fn(); + return { handle: { insertReferences, focus }, insertReferences, focus }; +} + +/** + * 「活跃聊天输入区」注册表的合同。 + * + * 这些语义就是 `App.tsx` 那处监听区分「引用落进草稿」与「这一步落空(要留痕)」的唯一依据, + * 所以在这里钉死;链路本身的端到端断言在 `resourceCanvasChatReferenceDrop.test.tsx` 与 + * `appSurface/*.suite.ts`。 + */ +describe('活跃聊天输入区注册表', () => { + // 注册表是模块级单例:用例之间靠对称注销回到空态,只在这里清 mock 记录。 + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('没有任何输入区挂载时插入返回 false', () => { + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + }); + + it('空批次不算成功,也不打扰已挂载的输入区', () => { + const { handle, insertReferences, focus } = composerHandle(); + const unregister = registerActiveChatComposer(handle); + expect(insertChatReferences([])).toBe(false); + expect(insertReferences).not.toHaveBeenCalled(); + expect(focus).not.toHaveBeenCalled(); + unregister(); + }); + + it('挂载期间:整批一次交给输入区、插入成功后才聚焦,返回 true', () => { + const { handle, insertReferences, focus } = composerHandle(); + const unregister = registerActiveChatComposer(handle); + expect( + insertChatReferences([ + resourceReference('hero'), + resourceReference('npc'), + ]), + ).toBe(true); + expect(insertReferences).toHaveBeenCalledTimes(1); + expect(insertReferences).toHaveBeenCalledWith([ + expect.objectContaining({ resourceId: 'hero' }), + expect.objectContaining({ resourceId: 'npc' }), + ]); + expect(focus).toHaveBeenCalledTimes(1); + unregister(); + }); + + it('句柄报「这一批没插进去」时返回 false,且不抢焦点', () => { + const { handle, focus } = composerHandle(false); + const unregister = registerActiveChatComposer(handle); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + expect(focus).not.toHaveBeenCalled(); + unregister(); + }); + + it('注销按身份校验:旧输入区卸载不会把已经接管的新输入区一起清掉', () => { + // 这条用例故意让两个句柄同时在册(重复注册的告警本身由下一条用例覆盖),先静音。 + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const first = composerHandle(); + const second = composerHandle(); + const unregisterFirst = registerActiveChatComposer(first.handle); + const unregisterSecond = registerActiveChatComposer(second.handle); + + // 旧链路后卸载(切换项目 / 两条链路互换时可能发生):新句柄必须留在注册表里。 + unregisterFirst(); + expect(insertChatReferences([resourceReference('hero')])).toBe(true); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + expect(first.insertReferences).not.toHaveBeenCalled(); + + unregisterSecond(); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + }); + + it('重复注册:后注册者接管并留一条告警,乱序注销也清不掉更新的句柄', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const first = composerHandle(); + const second = composerHandle(); + const third = composerHandle(); + + const unregisterFirst = registerActiveChatComposer(first.handle); + expect(warn).not.toHaveBeenCalled(); + + // 第二个注册:接线错误(两个聊天面同时挂载),后注册者接管,只在 dev 留线索。 + const unregisterSecond = registerActiveChatComposer(second.handle); + expect(warn.mock.calls.map((call) => String(call[0]))).toEqual([ + expect.stringContaining('第二个聊天输入区注册'), + ]); + expect(insertChatReferences([resourceReference('hero')])).toBe(true); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + + // 再注册第三个(模拟又一条链路接管):同样留线索。 + const unregisterThird = registerActiveChatComposer(third.handle); + expect(warn).toHaveBeenCalledTimes(2); + + // 乱序注销:第一个、第二个先卸载,都不能清掉当前接管的第三个。 + unregisterFirst(); + unregisterSecond(); + expect(insertChatReferences([resourceReference('npc')])).toBe(true); + expect(third.insertReferences).toHaveBeenCalledTimes(1); + expect(first.insertReferences).not.toHaveBeenCalled(); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + + unregisterThird(); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + warn.mockRestore(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 24b61408c..c1690feb9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -10,76 +10,51 @@ import { installUnhandledRejectionBridge } from '../unhandledRejectionBridge'; import { act, AuthenticatedClient, + createTauriEventBridge, expect, fireEvent, + installTauriRuntime, it, React, render, + resetTauriRuntime, screen, testAuthUser, vi, waitFor, } from './harness'; -type AuthEventBridge = { - listeners: Map void>; - emit: (event: string, payload: unknown) => void; -}; - /** * 注入 Tauri 事件替身。 * * jsdom 没有 `__TAURI_INTERNALS__`,`subscribeTauriEvent` 会退回 - * `window.__TAURI__.event.listen`,所以这里按同一入口登记回调。 + * `window.__TAURI__.event.listen`,所以按同一入口登记回调:安装走 + * harness.installTauriRuntime,事件注册 / 派发由 harness.createTauriEventBridge 负责。 */ -function installTauriBridge( - invoke: (command: string, args?: Record) => Promise, - options: { withEvents?: boolean } = {}, -): AuthEventBridge { - const listeners = new Map void>(); - window.__TAURI__ = { - core: { invoke }, - ...(options.withEvents - ? { - event: { - listen: async ( - event: string, - handler: (payload: { payload: unknown }) => void, - ) => { - listeners.set(event, handler); - return () => listeners.delete(event); - }, - }, - } - : {}), - } as never; - return { - listeners, - emit: (event, payload) => { - const handler = listeners.get(event); - if (handler) handler({ payload }); - }, - }; -} - function renderAuthSurface( invoke: (command: string, args?: Record) => Promise, options: { withEvents?: boolean } = {}, ) { - const bridge = installTauriBridge(invoke, options); + const bridge = createTauriEventBridge(); + installTauriRuntime({ + core: { invoke }, + ...(options.withEvents ? { event: { listen: bridge.listen } } : {}), + }); render( - React.createElement(AuthenticatedClient, null, ({ user, logout }) => - React.createElement( - 'main', - { 'aria-label': '已登录' }, - React.createElement('span', null, user.displayName), + // React 19 的 createElement 的 children 形参只接受 ReactNode,渲染函数子节点改传进 props。 + React.createElement(AuthenticatedClient, { + children: ({ user, logout }) => React.createElement( - 'button', - { type: 'button', onClick: logout }, - '退出', + 'main', + { 'aria-label': '已登录' }, + React.createElement('span', null, user.displayName), + React.createElement( + 'button', + { type: 'button', onClick: logout }, + '退出', + ), ), - ), - ), + }), ); return bridge; } @@ -118,7 +93,7 @@ function captureWindowRejections() { export function registerAuthTests() { afterEach(() => { resetPlatformSessionForTests(); - delete window.__TAURI__; + resetTauriRuntime(); }); it('throws a startup timeout through to unhandledrejection and leaves the loading state', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index e8c6f5ee7..3d875e617 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -21,6 +21,7 @@ import { createProjectChatRuntimeHarness, expect, fireEvent, + installTauriRuntime, it, pickProjectFromLauncher, React, @@ -108,10 +109,10 @@ async function openDirectCodexSurface( return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(DYNAMIC_GAME_PROJECT_PATH); const surface = await screen.findByLabelText('陶泥儿项目对话'); @@ -325,7 +326,7 @@ export function registerChatComposerControlTests() { const manifestAssets = [ { id: 'asset:hero', - kind: 'character', + kind: 'character' as const, mediaType: 'image/png', localPath: 'assets/hero.png', source: { kind: 'uploaded' as const }, diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index d96c09714..f19d826ae 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -1,9 +1,13 @@ +import type { Mock } from 'vitest'; + +import { dispatchResourceReferenceInsert } from '../../src/features/project-workspace/resourceReferences'; import { act, App, createProjectChatRuntimeHarness, expect, fireEvent, + installTauriRuntime, it, React, render, @@ -12,16 +16,43 @@ import { waitFor, } from './harness'; +type ChatRuntimeHarness = ReturnType; + +/** + * 把夹具的 invoke / listen 装进 `window.__TAURI__`:转换点只有 harness.installTauriRuntime 一处。 + */ +function installChatRuntime(harness: ChatRuntimeHarness) { + installTauriRuntime({ + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }); +} + +/** + * harness.invoke 的返回类型被 harness 内全部命令分支收敛成字面量联合,用例覆盖命令时要返回 + * 联合外的自定义值(例如导入文件路径),这里按固定签名 mock 取用,只放宽返回值类型。 + */ +type LooseInvokeMock = Mock< + [command: string, args?: Record], + Promise +>; + +function looseInvoke(harness: ChatRuntimeHarness): LooseInvokeMock { + return harness.invoke as unknown as LooseInvokeMock; +} + +// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。 +const AppComponent = App as React.ComponentType< + NonNullable> +>; + function renderDesignAgent( harness: ReturnType, ) { - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installChatRuntime(harness); window.history.pushState({}, '', '/'); return render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -127,13 +158,14 @@ export function registerDesignAgentSurfaceTests() { designAgentView: designConversationView(), designAgentContinueView: designConversationView(), }); - const originalInvoke = harness.invoke.getMockImplementation()!; + const invoke = looseInvoke(harness); + const originalInvoke = invoke.getMockImplementation()!; let failImport = true; let finishImport!: () => void; const importGate = new Promise((resolve) => { finishImport = resolve; }); - harness.invoke.mockImplementation(async (command, args) => { + invoke.mockImplementation(async (command, args) => { if (command === 'import_design_workspace_file') { await importGate; if (args?.fileName === '补充.md' && failImport) @@ -224,8 +256,9 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: designApprovalView(), }); - const originalInvoke = harness.invoke.getMockImplementation()!; - harness.invoke.mockImplementation(async (command, args) => + const invoke = looseInvoke(harness); + const originalInvoke = invoke.getMockImplementation()!; + invoke.mockImplementation(async (command, args) => command === 'import_design_workspace_file' ? 'references/需求.md' : originalInvoke(command, args), @@ -367,13 +400,10 @@ export function registerDesignAgentSurfaceTests() { ], }, }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installChatRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -411,9 +441,10 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: designConversationView(), }); - const originalInvoke = harness.invoke.getMockImplementation()!; + const invoke = looseInvoke(harness); + const originalInvoke = invoke.getMockImplementation()!; let finish!: (view: unknown) => void; - harness.invoke.mockImplementation((command, args) => + invoke.mockImplementation((command, args) => command === 'continue_design_agent_session' ? new Promise((resolve) => { finish = resolve; @@ -467,12 +498,13 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: { ...designConversationView(), messages: [history] }, }); - const originalInvoke = harness.invoke.getMockImplementation()!; + const invoke = looseInvoke(harness); + const originalInvoke = invoke.getMockImplementation()!; const requests: { clientTurnId: string; finish: (view: unknown) => void; }[] = []; - harness.invoke.mockImplementation((command, args) => + invoke.mockImplementation((command, args) => command === 'continue_design_agent_session' ? new Promise((resolve) => { requests.push({ @@ -489,7 +521,7 @@ export function registerDesignAgentSurfaceTests() { await setComposerText(input, '继续'); fireEvent.submit(input.closest('form')!); await waitFor(() => expect(requests).toHaveLength(1)); - const request = requests[0]; + const request = requests[0]!; const messageId = `${request.clientTurnId}:response:0`; const reply = '整块返回也逐步显示'; const terminal = { @@ -543,7 +575,7 @@ export function registerDesignAgentSurfaceTests() { screen.getByRole('button', { name: '思考中' }).hasAttribute('disabled'), ).toBe(true); expect(screen.queryByText('迟到旧回复')).toBeNull(); - await act(async () => requests[1].finish(terminal)); + await act(async () => requests[1]!.finish(terminal)); }); } @@ -732,13 +764,10 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: designApprovalView(), }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installChatRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -778,13 +807,10 @@ export function registerDesignAgentSurfaceTests() { }, }, }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installChatRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -816,13 +842,10 @@ export function registerDesignAgentSurfaceTests() { designAgentView: designConversationView(), designAgentContinueView: designConversationView(), }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installChatRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -886,13 +909,10 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: designHistoryView(), }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installChatRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -911,9 +931,40 @@ export function registerDesignAgentSurfaceTests() { (element) => element.getAttribute('data-agent-content') === 'process', ), ).toBe(true); - fireEvent.click(summaries[0]); - expect(details[0].open).toBe(true); - expect(details[1].open).toBe(false); - await waitFor(() => expect(summaries[0].textContent).toBe('思考过程')); + fireEvent.click(summaries[0]!); + expect(details[0]!.open).toBe(true); + expect(details[1]!.open).toBe(false); + await waitFor(() => expect(summaries[0]!.textContent).toBe('思考过程')); + }); + + it('画布派发的「引用」落进策划输入盒草稿', async () => { + const harness = createProjectChatRuntimeHarness({ + initialSessionExists: false, + }); + renderDesignAgent(harness); + const editor = await screen.findByLabelText('项目需求'); + + // 资源画布唯一的生产入口就是这个 window 事件;消费者在 `App.tsx`,落到哪份输入盒由 + // 「活跃聊天输入区」注册表回答。策划链路与普通项目共用一个消费者,这条用例钉的是 + // 它在 `PlanningChatView` 这一侧也真的进了草稿。 + act(() => { + dispatchResourceReferenceInsert({ + type: 'resource', + resourceId: 'planning-hero', + kind: 'character', + mediaType: 'image/png', + label: 'hero.png', + category: 'character', + tags: [], + source: 'resource-card', + }); + }); + + await waitFor(() => + expect( + editor.querySelectorAll('[data-resource-reference-id="planning-hero"]'), + ).toHaveLength(1), + ); + expect(editor.textContent).toContain('@hero.png'); }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 6f51990da..63ba99d30 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -15,6 +15,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { resetLlmModelCatalogCacheForTest } from '../../src/services/llmModelCatalog'; import { useLauncherHomeDraftStore } from '../../src/view/home/useHomeDraftStore'; +import { + createTauriEventBridge, + installTauriRuntime, + resetTauriRuntime, +} from '../tauriRuntimeFake'; const nativeClipboardMock = vi.hoisted(() => ({ text: '', @@ -195,20 +200,22 @@ function pickProjectFromLauncher( projectPath: string, action: '打开项目' | '新建项目' = '打开项目', ) { - const invoke = window.__TAURI__?.core?.invoke; - if (!invoke) { + const tauri = window.__TAURI__; + const invoke = tauri?.core?.invoke; + if (!tauri || !invoke) { throw new Error('Tauri invoke is not configured'); } - window.__TAURI__ = { - ...window.__TAURI__, + installTauriRuntime({ + ...tauri, core: { - ...window.__TAURI__.core, - invoke: async (command, args) => + ...tauri.core, + // 泛型 invoke 无法由非泛型包装函数精确表达:命中目录选择命令时固定返回路径,其余转发。 + invoke: (async (command: string, args?: Record) => command === 'pick_local_project_directory' ? projectPath - : invoke(command, args), + : invoke(command, args)) as unknown as typeof invoke, }, - }; + }); fireEvent.click(screen.getByRole('button', { name: action })); } @@ -787,13 +794,13 @@ function createProjectChatRuntimeHarness({ manifestInvalidatedHandlers.delete( handler as unknown as ManifestInvalidatedHandler, ); - if (progressHandler === handler) { + if ((progressHandler as unknown) === handler) { progressHandler = null; } - if (designAgentUpdateHandler === handler) { + if ((designAgentUpdateHandler as unknown) === handler) { designAgentUpdateHandler = null; } - if (directThreadNotifyHandler === handler) { + if ((directThreadNotifyHandler as unknown) === handler) { directThreadNotifyHandler = null; } }; @@ -933,6 +940,14 @@ function createProjectChatRuntimeHarness({ }; } +/** + * 安装测试用 Tauri 替身。 + * + * 定义已收进 `tests/tauriRuntimeFake.ts`(全仓唯一安装点),这里只转出,供 appSurface 各 + * suite 沿用既有的 `./harness` 导入路径。 + */ +export { createTauriEventBridge, installTauriRuntime, resetTauriRuntime }; + export function installResizeObserverStub() { let observerCount = 0; let observerDisconnected = false; @@ -1000,7 +1015,7 @@ afterEach(() => { window.history.pushState({}, '', '/'); window.localStorage.clear(); window.sessionStorage.clear(); - delete window.__TAURI__; + resetTauriRuntime(); vi.restoreAllMocks(); if (originalCreateObjectUrl) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index b933c7933..fad42f8b2 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -11,6 +11,7 @@ import { findResourceSelectButton, fireEvent, getResourceSelectButton, + installTauriRuntime, it, nativeClipboardMock, pickProjectFromLauncher, @@ -28,6 +29,12 @@ import { within, } from './harness'; +// App 的 props 参数带默认值,`React.ComponentProps` 会带上 undefined, +// createElement 因此推断不出 props;这里收窄成非空组件类型,运行时仍是同一个组件。 +const TestApp = App as React.ComponentType< + NonNullable> +>; + /** * 外壳级浮层提示的挂点。 * @@ -126,7 +133,7 @@ export function registerClientHomeTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); // 挂载即加载目录,触发按钮直接落在默认模型上,不出现「选择模型」空态。 @@ -174,7 +181,7 @@ export function registerClientHomeTests() { if (command === 'read_game_creator_app_config') return { config: {} }; throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); await waitFor(() => expect(invoke).toHaveBeenCalledWith('get_game_template_library_access'), @@ -256,7 +263,7 @@ export function registerClientHomeTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); // 首页推荐位只展示封面、标题、运行时与已下载徽标,本机灵感图库已随模板库上线删除。 @@ -308,7 +315,7 @@ export function registerClientHomeTests() { assets: [ { id: 'live-hero', - kind: 'icon-spritesheet', + kind: 'icon-spritesheet' as const, mediaType: 'image/png', localPath: 'assets/live-hero.png', source: { kind: 'generated' as const, taskId: 'art-asset-plan' }, @@ -398,7 +405,7 @@ export function registerClientHomeTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(WorkspaceLauncherShell, { currentUser: testAuthUser, @@ -540,10 +547,10 @@ export function registerClientHomeTests() { return runtimeHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: runtimeHarness.listen }, - }; + }); render( React.createElement(WorkspaceLauncherShell, { currentUser: testAuthUser, @@ -713,10 +720,10 @@ export function registerClientHomeTests() { return runtimeHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: runtimeHarness.listen }, - }; + }); render( React.createElement(WorkspaceLauncherShell, { currentUser: testAuthUser, @@ -818,7 +825,7 @@ export function registerClientHomeTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); expect(screen.getByLabelText('GameAgent 客户端')).not.toBeNull(); @@ -1007,7 +1014,7 @@ export function registerClientHomeTests() { return undefined; }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '会员与泥点' })); @@ -1120,7 +1127,7 @@ export function registerClientHomeTests() { } return undefined; }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '我的' })); @@ -1169,7 +1176,9 @@ export function registerClientHomeTests() { export function registerHomeProjectCreationTests() { it('scopes the 策划补全 option to the game entry and clears it on creation-type switch', () => { - window.__TAURI__ = { core: { invoke: vi.fn(async () => null) } }; + installTauriRuntime({ + core: { invoke: vi.fn(async () => null) }, + }); renderLauncherAt('/?launcher', 'home', true); const creationTypes = screen.getByRole('group', { name: '创作类型' }); @@ -1226,10 +1235,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); fireEvent.click(screen.getByRole('checkbox', { name: '策划补全' })); @@ -1282,7 +1291,7 @@ export function registerHomeProjectCreationTests() { 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/authorized-game']), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); expect(await screen.findByText('authorized-game')).not.toBeNull(); @@ -1332,7 +1341,7 @@ export function registerHomeProjectCreationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher('/tmp/missing-game'); @@ -1374,7 +1383,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); @@ -1427,7 +1436,7 @@ export function registerHomeProjectCreationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '打开项目' })); @@ -1460,7 +1469,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '打开项目' })); @@ -1480,7 +1489,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); const openButton = screen.getByRole('button', { name: '打开项目' }); @@ -1539,10 +1548,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); const creationTypes = screen.getByRole('group', { name: '创作类型' }); @@ -1686,10 +1695,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); const fileInput = @@ -1799,7 +1808,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher', 'home', true); const promptInput = screen.getByLabelText('创作想法'); @@ -1871,7 +1880,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher', 'home', true); await act(async () => { await vi.advanceTimersByTimeAsync(0); @@ -1965,10 +1974,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); // 做方案走立项链路:建项之后还要写策划运行时,这一步失败时项目目录已经存在。 @@ -2032,10 +2041,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); if (modeLabel === '做方案') { @@ -2129,10 +2138,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); fireEvent.click(screen.getByRole('button', { name: '做方案' })); @@ -2267,10 +2276,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); fireEvent.click(screen.getByRole('button', { name: '做方案' })); @@ -2333,10 +2342,10 @@ export function registerHomeProjectCreationTests() { return runtimeHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: runtimeHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); const promptInput = screen.getByLabelText('创作想法'); @@ -2363,7 +2372,7 @@ export function registerHomeProjectCreationTests() { const committedAssets = [ { id: 'direct-art-spec', - kind: 'icon-spec', + kind: 'icon-spec' as const, mediaType: 'image/png', localPath: 'assets/art-spec.png', source: { @@ -2373,7 +2382,7 @@ export function registerHomeProjectCreationTests() { }, { id: 'direct-game-background', - kind: 'scene', + kind: 'scene' as const, mediaType: 'image/png', localPath: 'assets/direct-game-background.png', source: { @@ -2383,7 +2392,7 @@ export function registerHomeProjectCreationTests() { }, { id: 'direct-art-spritesheet', - kind: 'icon-spritesheet', + kind: 'icon-spritesheet' as const, mediaType: 'image/png', localPath: 'assets/art-spritesheet.png', source: { @@ -2509,10 +2518,10 @@ export function registerHomeProjectCreationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( - React.createElement(App, { + React.createElement(TestApp, { initialProjectPath: projectPath, initialProjectManifest: manifest, initialPlanningPrompt: '继续修改已有项目', @@ -2593,10 +2602,10 @@ export function registerHomeProjectCreationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( - React.createElement(App, { + React.createElement(TestApp, { initialProjectPath: projectPath, initialProjectManifest: manifest, initialPlanningPrompt: '生成一个游戏', @@ -2627,7 +2636,7 @@ export function registerHomeProjectCreationTests() { // 重进项目:被拒消息不得从历史里被重放,拒绝文案也不该被当成历史条目。 render( - React.createElement(App, { + React.createElement(TestApp, { initialProjectPath: projectPath, initialProjectManifest: manifest, }), @@ -2704,7 +2713,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(paths), @@ -2775,7 +2784,7 @@ export function registerRecentProjectsTests() { return undefined; }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); expect(screen.getByLabelText('最近项目')).not.toBeNull(); @@ -2834,7 +2843,11 @@ export function registerRecentProjectsTests() { '星轨夜航', ); const invoke = vi.fn( - async (command: string, args?: Record) => { + // 回调体内要读 invoke.mock.calls,显式标注返回类型以打断类型推断的自引用环。 + async ( + command: string, + args?: Record, + ): Promise => { if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { const renamed = @@ -2861,7 +2874,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify([projectPath]), @@ -2909,7 +2922,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify([projectPath]), @@ -2958,7 +2971,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/recent-one']), @@ -2997,7 +3010,7 @@ export function registerRecentProjectsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher('/tmp/open-failed-game'); @@ -3047,7 +3060,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify([ @@ -3194,7 +3207,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/refreshable-game']), @@ -3259,7 +3272,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); expect(screen.getByLabelText('最近项目')).not.toBeNull(); @@ -3307,7 +3320,7 @@ export function registerRecentProjectsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '打开项目' })); @@ -3337,7 +3350,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3378,7 +3391,7 @@ export function registerRecentProjectsTests() { }, ); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3424,7 +3437,7 @@ export function registerRecentProjectsTests() { }, ); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3477,7 +3490,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3504,7 +3517,7 @@ export function registerRecentProjectsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3571,10 +3584,10 @@ export function registerRecentProjectsTests() { return supervisorHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: supervisorHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); // 设置 → 工作区:默认位置就是不选目录,仍然落在 AGC 管理的应用数据目录。 diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index 967302480..e0d3ae2d2 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -10,9 +10,11 @@ import { emptyProjectPolicy, expect, fireEvent, + installTauriRuntime, it, mockRoleAgentReply, renderAppAt, + resetTauriRuntime, roleAgentMockReply, screen, vi, @@ -192,7 +194,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); invoke.mockClear(); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); @@ -266,7 +268,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); invoke.mockClear(); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); @@ -352,7 +354,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); invoke.mockClear(); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); @@ -428,7 +430,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); const input = await screen.findByLabelText('Agent 对话内容'); @@ -520,7 +522,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); const input = await screen.findByLabelText('Agent 对话内容'); @@ -616,7 +618,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); @@ -685,12 +687,12 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); const input = await screen.findByLabelText('Agent 对话内容'); fireEvent.change(input, { target: { value: '确认运行环境提示' } }); - delete window.__TAURI__; + resetTauriRuntime(); fireEvent.submit(input.closest('form') as HTMLFormElement); expect(await screen.findByText('需要在 Tauri App 内运行')).not.toBeNull(); @@ -787,7 +789,7 @@ export function registerProjectConversationTests() { } throw new Error(`unexpected listen ${eventName}`); }); - window.__TAURI__ = { core: { invoke }, event: { listen } }; + installTauriRuntime({ core: { invoke }, event: { listen } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); const input = await screen.findByLabelText('Agent 对话内容'); @@ -876,7 +878,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); expect(await screen.findByText('旧 Agent 历史消息')).not.toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index b66ab4c34..866de0c7c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -2,6 +2,7 @@ import userEvent from '@testing-library/user-event'; import type { GameCreationAppAssetKind, + GameCreationAppManifest, ProjectResourceCanvasLayout, ProjectResourceCanvasPosition, } from '../../../../packages/shared/src/contracts/gameCreationApp'; @@ -10,7 +11,6 @@ import { GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND, GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, } from '../../../../packages/shared/src/contracts/gameCreationApp'; -import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences'; import { ApprovalModeDialog } from '../../src/view/project-development/ApprovalModeDialog'; import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout'; import { @@ -30,13 +30,16 @@ import { } from '../resourceGenerationPromptTestUtils'; import { act, + App, cleanup, createGameCreationAppManifest, + createProjectChatRuntimeHarness, expect, findResourceSelectButton, fireEvent, getResourceSelectButton, installResizeObserverStub, + installTauriRuntime, it, openResourceFilterPanel, ProjectDevelopmentView, @@ -51,6 +54,21 @@ import { within, } from './harness'; +// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。 +const AppComponent = App as React.ComponentType< + NonNullable> +>; + +/** + * 历史夹具沿用的旧 kind 字面量(如 `design-document` / `game-code`)已不在当前 + * `GameCreationAppAssetKind` 联合里;这些用例依赖未识别 kind 落「待归类」的运行时语义, + * 所以保留原始字符串,只在这里做类型层收口,不做别名归一。 + */ +function legacyAssetKind(kind: string): GameCreationAppAssetKind { + // 旧 kind 字面量与当前联合结构上不可兼容,这里显式转换以保留原始串的运行时语义。 + return kind as unknown as GameCreationAppAssetKind; +} + function resourceGraphForInputs(args?: Record) { const resources = (args?.resources as @@ -311,7 +329,7 @@ function installResourceBookBottomToolbarInvoke( throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); return { calls }; } @@ -561,7 +579,7 @@ export function registerProjectWorkbenchFoundationTests() { source: { kind: 'generated', taskId: 'art-asset-plan' }, }, ]; - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: vi.fn( async (command: string, args?: Record) => { @@ -595,7 +613,7 @@ export function registerProjectWorkbenchFoundationTests() { }, ), }, - }; + }); render( React.createElement(ProjectDevelopmentView, { @@ -686,7 +704,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets = [ { id: 'section-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/section.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -707,7 +725,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'section-code', - kind: 'game-code', + kind: legacyAssetKind('game-code'), mediaType: 'text/javascript', localPath: 'game/section.js', source: { kind: 'generated', taskId: 'code-prototype' }, @@ -767,7 +785,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1340,7 +1358,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1584,7 +1602,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1706,7 +1724,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1797,7 +1815,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1939,7 +1957,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2075,7 +2093,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2172,7 +2190,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2232,7 +2250,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2294,7 +2312,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2357,7 +2375,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2455,7 +2473,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const viewProps = { projectName: manifest.name, projectPath: '/tmp/workbench-resource-unread', @@ -2482,7 +2500,7 @@ export function registerProjectWorkbenchFoundationTests() { nextProjectManifest.assets = [ { id: 'next-project-code', - kind: 'game-code', + kind: legacyAssetKind('game-code'), mediaType: 'text/javascript', localPath: 'game/game.js', source: { kind: 'generated' }, @@ -2518,7 +2536,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets = [ { id: 'nav-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/nav-document.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -2549,7 +2567,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2635,7 +2653,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets = [ { id: 'imported-design-doc', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'docs/plan.md', source: { kind: 'generated' }, @@ -2674,7 +2692,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const viewProps = { projectName: manifest.name, projectPath: '/tmp/workbench-imported-image-canvas', @@ -2697,7 +2715,7 @@ export function registerProjectWorkbenchFoundationTests() { id: 'imported-image', // canonical `icon` → 「UI 交互」:本用例钉的是"同数量 manifest 更新后栏目跟着变", // 不掺 UI 设计图「(待视觉验收)」标签那套,所以选一个不会带来额外后缀的 kind。 - kind: 'icon', + kind: 'icon' as const, mediaType: 'image/png', localPath: 'assets/uploads/local-imported-image.png', source: { @@ -2728,7 +2746,7 @@ export function registerProjectWorkbenchFoundationTests() { 'workbench-empty-section-overview', '空资源项目', ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: vi.fn( async (command: string, args?: Record) => { @@ -2762,7 +2780,7 @@ export function registerProjectWorkbenchFoundationTests() { }, ), }, - }; + }); render( React.createElement(ProjectDevelopmentView, { @@ -2843,7 +2861,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'design-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/design.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -2926,7 +2944,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const play = vi .spyOn(HTMLMediaElement.prototype, 'play') .mockResolvedValue(undefined); @@ -3226,7 +3244,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const props = { projectName: manifest.name, projectPath: '/tmp/workbench-preview-scheduler', @@ -3596,7 +3614,7 @@ export function registerProjectWorkbenchFoundationTests() { ); }); - it('工具条里的「引用」用键盘也能插进聊天输入框', async () => { + it('工具条里的「引用」把素材 @ 进真实聊天草稿(键盘与鼠标两条通路)', async () => { const manifest = createGameCreationAppManifest( 'workbench-resource-reference', '引用入口测试', @@ -3611,6 +3629,13 @@ export function registerProjectWorkbenchFoundationTests() { source: { kind: 'generated' }, }); let layoutRevision = 0; + const projectPath = '/tmp/workbench-resource-reference'; + // 对话面用真实 DirectProject 链路(`App` → `DirectProjectChatView` → `DirectProjectComposer`): + // 引用事件的消费者在 `App.tsx`,桩接不住它,链路断点正好在「谁把它落进草稿」。 + const chatHarness = createProjectChatRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { @@ -3648,20 +3673,45 @@ export function registerProjectWorkbenchFoundationTests() { dataUrl: 'data:image/png;base64,iVBORw0KGgo=', }; } - throw new Error(`unexpected invoke ${command}`); + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'get_design_agent_runtime_mode') { + return null; + } + return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ + core: { invoke }, + event: { listen: chatHarness.listen }, + }); render( React.createElement(ProjectDevelopmentView, { projectName: '引用入口测试', - projectPath: '/tmp/workbench-resource-reference', + projectPath, manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, - chat: React.createElement('div', null, '项目总控'), + chat: React.createElement(AppComponent, { + initialProjectPath: projectPath, + }), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -3689,49 +3739,39 @@ export function registerProjectWorkbenchFoundationTests() { '引用资源 hero.png', ); - const inserted: unknown[] = []; - const onInsert = (event: Event) => { - inserted.push( - (event as CustomEvent<{ reference: unknown }>).detail.reference, - ); - }; - window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert); - try { - // 键盘通路:聚焦后回车确认。App 侧监听同一个事件并把它插成输入框里的引用 chip - // (`App.tsx` 的 `handleResourceReferenceInsert`),所以这里钉的是真链路而不是按钮长相。 - referenceButton.focus(); - expect(document.activeElement).toBe(referenceButton); - await userEvent.setup().keyboard('{Enter}'); - expect(inserted).toHaveLength(1); + // 端到端契约:交付结果是草稿里的引用芯片,不是 window 上的一次 dispatch。 + // 链路断点一直是「谁把引用落进草稿」,所以断言必须读真实输入盒的 DOM。 + const draftChipIds = () => + Array.from( + document.querySelectorAll( + 'form.project-chat-composer [data-resource-reference-id]', + ), + ).map((chip) => chip.getAttribute('data-resource-reference-id')); + const composerEditor = () => + document.querySelector( + 'form.project-chat-composer [contenteditable="true"][aria-label="陶泥儿对话内容"]', + )!; + // 芯片显示名走聊天自己的 manifest 口径(文件名去扩展名),与画布卡片上的文件名不同。 + const firstChipLabel = () => + document.querySelector( + 'form.project-chat-composer [data-resource-reference-id] .resource-reference-chip-label', + )?.textContent; - // 鼠标通路仍然只派发一次;两条通路带的是逐字相同的出站负载。 - fireEvent.click(referenceButton); - expect(inserted).toHaveLength(2); - } finally { - window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert); - } - expect(inserted).toEqual([ - { - type: 'resource', - resourceId: 'scene-hero', - kind: 'image', - mediaType: 'image/png', - label: 'hero.png', - category: 'scene', - tags: ['主舞台'], - source: 'resource-card', - }, - { - type: 'resource', - resourceId: 'scene-hero', - kind: 'image', - mediaType: 'image/png', - label: 'hero.png', - category: 'scene', - tags: ['主舞台'], - source: 'resource-card', - }, - ]); + // 键盘通路:聚焦后回车确认。 + referenceButton.focus(); + expect(document.activeElement).toBe(referenceButton); + await userEvent.setup().keyboard('{Enter}'); + await waitFor(() => expect(draftChipIds()).toEqual(['scene-hero'])); + expect(firstChipLabel()).toBe('hero'); + expect(composerEditor().textContent).toContain('@hero'); + + // 鼠标通路:再点一次,第二枚芯片接在第一枚之后——光标留在插入之后,不覆盖已插的引用。 + fireEvent.click(referenceButton); + await waitFor(() => + expect(draftChipIds()).toEqual(['scene-hero', 'scene-hero']), + ); + // 插入后焦点回到输入盒,用户可以接着打字。 + expect(document.activeElement).toBe(composerEditor()); }); it('信息面板在画布浮层与运行页签里渲染同一份只读字段', async () => { @@ -3800,7 +3840,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -3900,7 +3940,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets.push( { id: 'design-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'game/design.md', source: { kind: 'generated' }, @@ -3921,14 +3961,14 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'audio-bgm', - kind: 'bgm', + kind: legacyAssetKind('bgm'), mediaType: 'audio/mpeg', localPath: 'assets/bgm.mp3', source: { kind: 'generated' }, }, { id: 'blocked-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'game/blocked.md', source: { kind: 'generated' }, @@ -4017,7 +4057,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); render( @@ -4190,7 +4230,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); const viewProps = { projectName: manifest.name, @@ -4260,7 +4300,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets.push( { id: 'dependency-spec', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/spec-source.json', source: { @@ -4271,7 +4311,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'dependency-ui', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/ui-dependency.json', source: { @@ -4283,7 +4323,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'unrelated-cycle', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/unrelated-cycle.json', source: { @@ -4440,7 +4480,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const view = render( React.createElement(ProjectDevelopmentView, { @@ -4963,11 +5003,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '延迟依赖图测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5050,11 +5094,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const renderView = (agentResults: ProjectAgentResultSummary[]) => React.createElement(ProjectDevelopmentView, { projectName: '稳定依赖图测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5117,7 +5165,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets.push( { id: 'truncated-depth-0', - kind: 'design-spec', + kind: legacyAssetKind('design-spec'), mediaType: 'application/json', localPath: 'assets/truncated-depth-0.json', source: { @@ -5127,7 +5175,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'truncated-depth-1', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/truncated-depth-1.json', source: { @@ -5138,7 +5186,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'truncated-depth-2', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/truncated-depth-2.json', source: { @@ -5297,11 +5345,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '截断依赖图布局保护测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5389,12 +5441,16 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); function renderWorkbench() { render( React.createElement(ProjectDevelopmentView, { projectName: '布局持久化测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5529,11 +5585,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '卡片点击不误判拖动测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5608,7 +5668,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -5633,18 +5693,18 @@ export function registerProjectWorkbenchFoundationTests() { hasPointerCapture: { configurable: true, value: vi.fn(() => true) }, releasePointerCapture: { configurable: true, value: vi.fn() }, }); - fireEvent.pointerDown(card, { + fireEvent.pointerDown(card!, { pointerId: 41, button: 0, clientX: 40, clientY: 50, }); - fireEvent.pointerMove(card, { + fireEvent.pointerMove(card!, { pointerId: 41, clientX: 120, clientY: 50, }); - fireEvent.pointerCancel(card, { pointerId: 41 }); + fireEvent.pointerCancel(card!, { pointerId: 41 }); fireEvent.click(card!); expect(card?.querySelector('[aria-pressed="true"]')).not.toBeNull(); @@ -5712,11 +5772,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '资源子类型排序测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath: '/tmp/workbench-layout-asset-subtypes', manifest, attachments: [], @@ -5776,11 +5840,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '布局失败测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath: '/tmp/workbench-layout-save-failure', manifest, attachments: [], @@ -6067,7 +6135,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, @@ -6211,7 +6279,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const viewProps = { projectName: manifest.name, projectPath: '/tmp/workbench-preview-scope-switch-a', @@ -6427,7 +6495,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const onPlay = vi.fn(); render( @@ -6604,7 +6672,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, @@ -6613,7 +6681,7 @@ export function registerProjectWorkbenchFoundationTests() { attachments: [], recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -6717,7 +6785,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); expect(screen.queryByRole('button', { name: '调度 Ready' })).toBeNull(); @@ -6748,7 +6816,7 @@ export function registerProjectAgentStatusTests() { manifest.assets = [ { id: 'section-card-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/section-card.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -6772,7 +6840,7 @@ export function registerProjectAgentStatusTests() { }, { id: 'section-card-unclassified', - kind: 'game-code', + kind: legacyAssetKind('game-code'), mediaType: 'text/javascript', localPath: 'game/section-card.js', source: { kind: 'generated', taskId: 'code-prototype' }, @@ -6804,7 +6872,7 @@ export function registerProjectAgentStatusTests() { const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { - return resourceGraphForInputs([]); + return resourceGraphForInputs(); } if (command === 'read_local_project_resource_canvas_layout') { return { @@ -6833,7 +6901,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -6894,7 +6962,7 @@ export function registerProjectAgentStatusTests() { manifest.assets = [ { id: 'all-resources-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/all-resources.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -6932,7 +7000,7 @@ export function registerProjectAgentStatusTests() { const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { - return resourceGraphForInputs([]); + return resourceGraphForInputs(); } if (command === 'read_local_project_resource_canvas_layout') { return { @@ -6961,7 +7029,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -7232,7 +7300,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -7313,14 +7381,14 @@ export function registerProjectAgentStatusTests() { manifest.assets = [ { id: 'overview-heading-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/overview-heading.md', source: { kind: 'generated', taskId: 'design-foundation' }, category: 'document', }, ]; - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: vi.fn( async (command: string, args?: Record) => { @@ -7354,7 +7422,7 @@ export function registerProjectAgentStatusTests() { }, ), }, - }; + }); render( React.createElement(ProjectDevelopmentView, { @@ -7426,7 +7494,7 @@ export function registerProjectAgentStatusTests() { }, { id: 'toolbar-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/toolbar-notes.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -7831,7 +7899,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -8013,7 +8081,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -8105,7 +8173,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -8446,7 +8514,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index 38595583d..c972a52bc 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -11,6 +11,7 @@ import { fireEvent, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, type GameCreationAgentRunTrace, + installTauriRuntime, it, renderLauncherAt, screen, @@ -111,6 +112,9 @@ export function registerAgentStatusDerivationTests() { pass: 1, agent: 'Evaluator', phase: 'evaluation', + taskId: null, + group: null, + role: null, status: 'passed', inputPaths: ['.agent/passes/pass-1/draft.json', '.agent/findings.md'], outputPaths: ['.agent/findings.md'], @@ -119,11 +123,14 @@ export function registerAgentStatusDerivationTests() { { toolId: 'agent.evaluate', status: 'ok', + inputPaths: [], + outputPaths: [], summary: 'Evaluator 质量评审', }, ], }, ], + artifacts: [], taskGraph: { goal: '做一个厨房弹幕游戏', readyTaskIds: ['audio-director'], @@ -220,7 +227,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -269,7 +276,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -338,7 +345,7 @@ export function registerRuntimeSettingsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -412,7 +419,7 @@ export function registerRuntimeSettingsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -484,7 +491,7 @@ export function registerRuntimeSettingsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -536,7 +543,7 @@ export function registerRuntimeSettingsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -617,7 +624,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); document.documentElement.style.overflow = 'auto'; document.body.style.overflow = 'scroll'; renderLauncherAt('/?launcher'); @@ -659,7 +666,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -715,7 +722,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); // 首页模型选择器挂载后会在目录请求之后读取一次配置,等它完成再打开对话框。 diff --git a/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts index 7ed499f06..c66d028ad 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts @@ -58,8 +58,8 @@ export function registerToolCallGroupTests() { 'running', 'completed', ]); - expect(within(rows[0]).getByText('失败')).not.toBeNull(); - expect(within(rows[2]).queryByText('失败')).toBeNull(); + expect(within(rows[0]!).getByText('失败')).not.toBeNull(); + expect(within(rows[2]!).queryByText('失败')).toBeNull(); }); it('summarizes the group as its own operation count', () => { @@ -458,9 +458,9 @@ export function registerToolCallGroupTests() { view.rerender( React.createElement(ToolCallGroup, { calls: [ - { ...calls[0], status: 'completed' as const, updatedAt: lastEnd }, + { ...calls[0]!, status: 'completed' as const, updatedAt: lastEnd }, { - ...calls[1], + ...calls[1]!, status: 'completed' as const, updatedAt: lastEnd - 400, }, diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index b088f8435..7d4b6d79e 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -28,6 +28,7 @@ import { invoke } from '@tauri-apps/api/core'; import { AuthenticatedClient } from '../src/app/AuthenticatedClient'; import { resetPlatformSessionForTests } from '../src/services/platformSession'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; import { installUnhandledRejectionBridge } from './unhandledRejectionBridge'; type AuthInvoke = ( @@ -42,11 +43,12 @@ function reportCalls() { } function renderLogin(invokeAuth: AuthInvoke) { - window.__TAURI__ = { core: { invoke: invokeAuth } } as never; + installTauriRuntime({ core: { invoke: invokeAuth } }); render( - createElement(AuthenticatedClient, null, () => - createElement('main', { 'aria-label': '已登录' }), - ), + // React 19 的 createElement 的 children 形参只接受 ReactNode,渲染函数子节点改编进 props。 + createElement(AuthenticatedClient, { + children: () => createElement('main', { 'aria-label': '已登录' }), + }), ); } @@ -67,7 +69,7 @@ describe('认证失败的上报判据', () => { afterEach(() => { cleanup(); resetPlatformSessionForTests(); - delete window.__TAURI__; + resetTauriRuntime(); vi.mocked(invoke).mockClear(); }); diff --git a/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx index 48d990264..c67af4f15 100644 --- a/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx +++ b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx @@ -7,6 +7,7 @@ import { useDirectProjectChatController, } from '../src/view/project-development/chat/controller/useDirectProjectChatController'; import type { DirectCodexTurnAttachment } from '../src/view/project-development/chat/conversation/directCodexTurnAttachments'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; // jsdom 的 Blob/File 没实现 `arrayBuffer`,而附件导入链路要按字节读文件。 if (typeof Blob.prototype.arrayBuffer !== 'function') { @@ -25,7 +26,7 @@ function files(count: number, prefix: string) { afterEach(() => { cleanup(); vi.restoreAllMocks(); - window.__TAURI__ = undefined; + resetTauriRuntime(); }); describe('聊天输入盒的附件上限', () => { @@ -38,7 +39,7 @@ describe('聊天输入盒的附件上限', () => { const fileName = (args as { fileName?: string }).fileName ?? 'file'; return { localPath: `assets/upload-${(uploadSeq += 1)}-${fileName}` }; }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useDirectProjectChatController({ @@ -65,7 +66,7 @@ describe('聊天输入盒的附件上限', () => { }); expect(imported[0]).toHaveLength(5); expect(imported[1]).toHaveLength(3); - expect(imported[0].length + imported[1].length).toBe( + expect(imported[0]!.length + imported[1]!.length).toBe( MAX_CHAT_COMPOSER_ATTACHMENTS, ); }); diff --git a/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx b/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx index 043f7ec8d..9568be6b7 100644 --- a/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx +++ b/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx @@ -10,6 +10,7 @@ import { beginProjectOpenAnalytics, beginUiSaveAnalytics, } from '../src/services/clientAnalytics'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const context = { route: { user_id: 'user-a', destination_origin: 'https://a.example' }, @@ -27,7 +28,7 @@ function deferred() { } afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); vi.restoreAllMocks(); }); @@ -54,11 +55,12 @@ function mount( return null; }, ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); const hook = renderHook( () => useHomeProjectCreation({ setStatus: vi.fn(), + showTransientNotice: vi.fn(), setLauncherView: vi.fn(), rememberRecentWorkspace: vi.fn(), }), @@ -88,7 +90,7 @@ it.each(['recent', 'picker', 'create'] as const)( ); }); expect(records()).toHaveLength(1); - expect(records()[0][1]).toMatchObject({ context, openSource: source }); + expect(records()[0]![1]).toMatchObject({ context, openSource: source }); }, ); @@ -105,7 +107,7 @@ it('身份抓取挂起不阻塞打开,成功时间在抓取完成前冻结', a captured.resolve(context); }); expect(records()).toHaveLength(1); - expect(Date.parse(String(records()[0][1]?.eventTime))).toBeLessThanOrEqual( + expect(Date.parse(String(records()[0]![1]?.eventTime))).toBeLessThanOrEqual( latestSuccessTime, ); }); @@ -179,7 +181,7 @@ it('同项目再次显式打开是新操作,重渲染不会产生额外打开' await result.current.openProject('C:/project', 'open'); }); expect(records()).toHaveLength(2); - expect(records()[0][1]?.operationId).not.toBe(records()[1][1]?.operationId); + expect(records()[0]![1]?.operationId).not.toBe(records()[1]![1]?.operationId); }); it('身份抓取和记录写入失败静默,不改变成功打开', async () => { diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index d8b0b27a2..d3a43aa58 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -24,6 +24,7 @@ import type { RuntimeSessionInstallFailed } from '../src/services/generated/Runt import type { SendCodeRejected } from '../src/services/generated/SendCodeRejected'; import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected'; import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; function expectNever(value: never): void { void value; @@ -127,7 +128,7 @@ function variantMessage(error: ClientAuthError): string { } function installInvoke(handler: () => Promise) { - window.__TAURI__ = { core: { invoke: vi.fn(handler) } } as never; + installTauriRuntime({ core: { invoke: vi.fn(handler) } }); } describe('认证命令失败的 JS 侧载体', () => { @@ -150,7 +151,7 @@ describe('认证命令失败的 JS 侧载体', () => { }); it('认证桥未安装时保持原样抛出,不包装成命令失败', async () => { - delete window.__TAURI__; + resetTauriRuntime(); await expect( loginClientWithPassword( diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index 6c6393676..4bea6f174 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -18,6 +18,7 @@ import { refreshClientAuthSession, sendClientPhoneLoginCode, } from '../src/services/clientAuth'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const testUser = { id: 'user-1', @@ -35,9 +36,9 @@ const testUser = { beforeEach(() => { invoke.mockReset(); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: (...args: unknown[]) => invoke(...args) }, - } as never; + }); }); test('手机号归一化只保留可提交的纯号码', () => { @@ -144,7 +145,7 @@ test('续期结果区分成功、失效与身份变化', async () => { }); test('没有原生宿主时登录能力明确失败关闭', async () => { - delete window.__TAURI__; + resetTauriRuntime(); await expect(readClientAuthState()).rejects.toThrow( '需要在 Tauri App 内登录', ); diff --git a/apps/ai-game-creator-shell/tests/clientHttp.test.ts b/apps/ai-game-creator-shell/tests/clientHttp.test.ts index 30b480e52..2f9dfa43f 100644 --- a/apps/ai-game-creator-shell/tests/clientHttp.test.ts +++ b/apps/ai-game-creator-shell/tests/clientHttp.test.ts @@ -89,7 +89,7 @@ test('预设映射到渠道地址,自定义走归一化', async () => { }); test('开发态切换服务器会落盘并在内存中生效', async () => { - vi.stubEnv('DEV', true); + vi.stubEnv('DEV', 'true'); const mod = await loadClientHttp(); expect(mod.isClientServerSelectionEnabled()).toBe(true); @@ -108,7 +108,7 @@ test('开发态切换服务器会落盘并在内存中生效', async () => { }); test('开发态从 Rust 偏好恢复服务器选择', async () => { - vi.stubEnv('DEV', true); + vi.stubEnv('DEV', 'true'); const mod = await loadClientHttp(); invoke.mockResolvedValue({ clientServerSelection: { diff --git a/apps/ai-game-creator-shell/tests/clientRuntimeErrorBoundary.test.ts b/apps/ai-game-creator-shell/tests/clientRuntimeErrorBoundary.test.ts index 23d82084d..b384874b9 100644 --- a/apps/ai-game-creator-shell/tests/clientRuntimeErrorBoundary.test.ts +++ b/apps/ai-game-creator-shell/tests/clientRuntimeErrorBoundary.test.ts @@ -1,12 +1,13 @@ /** @vitest-environment jsdom */ import { fireEvent, render, screen } from '@testing-library/react'; -import { createElement } from 'react'; +import { createElement, type ReactNode } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ClientRuntimeErrorBoundary } from '../src/app/AuthenticatedClient'; -function BrokenAuthenticatedContent() { +// 该组件永远抛错,声明 ReactNode 返回类型才能作为 children 传给错误边界。 +function BrokenAuthenticatedContent(): ReactNode { throw new Error('首页渲染失败'); } @@ -20,11 +21,11 @@ describe('AGC authenticated client error boundary', () => { const onLogout = vi.fn(); render( - createElement( - ClientRuntimeErrorBoundary, - { onLogout }, - createElement(BrokenAuthenticatedContent), - ), + createElement(ClientRuntimeErrorBoundary, { + onLogout, + // `children` 是必填 prop(createElement 第三参不再满足必填 props 约束)。 + children: createElement(BrokenAuthenticatedContent), + }), ); expect( diff --git a/apps/ai-game-creator-shell/tests/cocosProjectOpen.test.tsx b/apps/ai-game-creator-shell/tests/cocosProjectOpen.test.tsx index 26a218ac1..d1f52a7ac 100644 --- a/apps/ai-game-creator-shell/tests/cocosProjectOpen.test.tsx +++ b/apps/ai-game-creator-shell/tests/cocosProjectOpen.test.tsx @@ -4,9 +4,10 @@ import { afterEach, expect, test, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); }); test('打开 Cocos 目录走导入入口,不创建 Web 脚手架或发送初始消息', async () => { @@ -38,13 +39,15 @@ test('打开 Cocos 目录走导入入口,不创建 Web 脚手架或发送初 } throw new Error(`意外调用:${command}`); }); - window.__TAURI__ = { core: { invoke } }; + // TauriInvoke 是泛型函数签名,vi.fn 的具体返回类型无法直接赋给它,这里只在装配处桥接。 + installTauriRuntime({ core: { invoke } }); const setLauncherView = vi.fn(); const { result } = renderHook(() => useHomeProjectCreation({ setStatus: vi.fn(), + // 现行 UseHomeProjectCreationOptions 必填 showTransientNotice,旧的 setAgentChatProjectPath 已移除。 + showTransientNotice: vi.fn(), setLauncherView, - setAgentChatProjectPath: vi.fn(), rememberRecentWorkspace: vi.fn(), }), ); diff --git a/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx b/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx index 3a14cdc6a..d4c2d3b09 100644 --- a/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx +++ b/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx @@ -18,6 +18,7 @@ import type { } from '../src/app/types'; import { CustomLlmSettings } from '../src/features/runtime-config/CustomLlmSettings'; import { RuntimeConfigDialog } from '../src/features/runtime-config/RuntimeConfigDialog'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const invoke = vi.fn(); const llm = { @@ -34,11 +35,11 @@ function Harness() { beforeEach(() => { invoke.mockReset(); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); }); afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); }); test('reads endpoint models, filters candidates, and previews only checked models', async () => { diff --git a/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx b/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx index aaf335618..c5c5f6878 100644 --- a/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx +++ b/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx @@ -4,23 +4,25 @@ import { afterEach, expect, it, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); }); const projectPath = 'C:/test/design-project'; const manifest = createGameCreationAppManifest('test-project', '策划项目'); -function setup(invoke: ReturnType) { - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; +function setup( + invoke: (command: string, args?: Record) => Promise, +) { + installTauriRuntime({ core: { invoke } }); return renderHook(() => useHomeProjectCreation({ setStatus: vi.fn(), showTransientNotice: vi.fn(), setLauncherView: vi.fn(), - setAgentChatProjectPath: vi.fn(), rememberRecentWorkspace: vi.fn(), }), ); diff --git a/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx b/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx index e51bb4fb3..a19b54fbe 100644 --- a/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx +++ b/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx @@ -5,7 +5,9 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import type { DesignView } from '../src/app/types'; import { useDesignReplyAnimation } from '../src/view/project-development/planning/useDesignReplyAnimation'; -beforeEach(() => vi.useFakeTimers()); +beforeEach(() => { + vi.useFakeTimers(); +}); afterEach(() => { cleanup(); vi.useRealTimers(); @@ -39,7 +41,7 @@ it('keeps each reply progress across tool snapshots and repeated completion', () result.current.receiveText(first.id, first.text); vi.advanceTimersByTime(100); }); - expect(result.current.replies[0].visible).toBe('第一'); + expect(result.current.replies[0]!.visible).toBe('第一'); act(() => result.current.receiveView( view( @@ -49,7 +51,7 @@ it('keeps each reply progress across tool snapshots and repeated completion', () ), ); expect(result.current.replies).toHaveLength(1); - expect(result.current.replies[0].visible).toBe('第一'); + expect(result.current.replies[0]!.visible).toBe('第一'); act(() => result.current.receiveText(second.id, second.text)); const terminal = view([history, first, second]); act(() => { @@ -74,9 +76,9 @@ it('resets only an unpersisted retry attempt and discards failed partial output' result.current.receiveText('t:response:0', '尝试失败'); vi.advanceTimersByTime(100); }); - expect(result.current.replies[0].visible).toBe('尝试'); + expect(result.current.replies[0]!.visible).toBe('尝试'); act(() => result.current.receiveText('t:response:0', '')); - expect(result.current.replies[0].visible).toBe(''); + expect(result.current.replies[0]!.visible).toBe(''); const saved = { id: 't:response:0', role: 'assistant', text: '成功' }; act(() => { result.current.receiveText(saved.id, saved.text); diff --git a/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx b/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx index 55f9b4082..0e9949803 100644 --- a/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx +++ b/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx @@ -12,6 +12,7 @@ import React from 'react'; import { afterEach, expect, it, vi } from 'vitest'; import { DesignWorkspacePanel } from '../src/features/project-workspace/DesignWorkspacePanel'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; vi.mock('../src/components/ChatMarkdownMessage', () => ({ ChatMarkdownMessage: () => null, @@ -20,7 +21,7 @@ vi.mock('../src/components/ChatMarkdownMessage', () => ({ afterEach(() => { cleanup(); vi.unstubAllEnvs(); - delete window.__TAURI__; + resetTauriRuntime(); }); it('shows imported references after a workspace event without reloading the design session', async () => { @@ -41,7 +42,7 @@ it('shows imported references after a workspace event without reloading the desi if (command === 'read_design_workspace_file') return '# 导入的需求'; throw new Error(`Unexpected command: ${command}`); }); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: vi.fn(async (_name, callback) => { @@ -49,7 +50,7 @@ it('shows imported references after a workspace event without reloading the desi return () => undefined; }), }, - } as unknown as typeof window.__TAURI__; + }); render(); await waitFor(() => expect(onUpdate).toBeDefined()); await waitFor(() => @@ -95,7 +96,7 @@ it('prepares debug fixtures from the header and refreshes the phase and files wi } throw new Error(`Unexpected command: ${command}`); }); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); render(); await waitFor(() => expect( diff --git a/apps/ai-game-creator-shell/tests/dev-port.test.ts b/apps/ai-game-creator-shell/tests/dev-port.test.ts index e7a8f3036..b72ac4eb8 100644 --- a/apps/ai-game-creator-shell/tests/dev-port.test.ts +++ b/apps/ai-game-creator-shell/tests/dev-port.test.ts @@ -4,14 +4,77 @@ import { createAgcAdminWebEndpoint, createAgcDevEndpoint, readConfiguredAgcAdminWebPort, - resolveAgcAdminWebEndpoint, - resolveAgcDevEndpoint, + resolveAgcAdminWebEndpoint as resolveAgcAdminWebEndpointImpl, + resolveAgcDevEndpoint as resolveAgcDevEndpointImpl, withAgcDevEndpointEnv, } from '../scripts/dev-port.mjs'; +// scripts/dev-port.mjs 没有 JSDoc,TS 按 JS 默认值把可选参数推成了过窄类型 +// (findPort 的 portRange 推成 null、reservedPorts 推成 never[]),与测试注入的 mock 结构不兼容。 +// 下面按运行时真实契约重新声明局部签名,只做类型层校正,调用方式与行为完全不变。 +type EndpointPortRange = { start: number; end: number; label: string }; + +type AgcDevEndpointLike = { + host: string; + port: number; + url: string; + markerUrl: string; + portRange: EndpointPortRange | null; +}; + +type AgcAdminWebEndpointLike = { + host: string; + port: number; + origin: string; + basePath: string; + url: string; + portRange: EndpointPortRange | null; +}; + +type FindPortOptions = { + host?: string; + preferredPort: number; + reservedPorts?: Set; + maxAttempts?: number | null; + portRange?: EndpointPortRange | null; + strict?: boolean; +}; + +type ReservePortRangeResult = + | { range?: EndpointPortRange | null } + | null + | undefined; + +type ResolveAgcDevEndpointOptions = { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + strictConfigured?: boolean; + reservePortRange?: (options?: { + env?: NodeJS.ProcessEnv; + }) => Promise; + findPort?: (options: FindPortOptions) => number | Promise; +}; + +type ResolveAgcAdminWebEndpointOptions = ResolveAgcDevEndpointOptions & { + reservedPorts?: number[]; +}; + +// 导入实现本身是同一份,仅按运行时契约修正推导过窄的签名(js 无 JSDoc 导致结构上不兼容)。 +const resolveAgcDevEndpoint = resolveAgcDevEndpointImpl as unknown as ( + options?: ResolveAgcDevEndpointOptions, +) => Promise; + +// 同上:reservedPorts 在 JS 推断里被收窄为 never[],与实际可传入的保留端口数组不兼容。 +const resolveAgcAdminWebEndpoint = + resolveAgcAdminWebEndpointImpl as unknown as ( + options?: ResolveAgcAdminWebEndpointOptions, + ) => Promise; + describe('AI 游戏创作 dev 端口', () => { test('Linux 使用用户端口段的 start + 5 槽位', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); const endpoint = await resolveAgcDevEndpoint({ env: { USER: 'alice', LOGNAME: 'alice' }, platform: 'linux', @@ -44,7 +107,7 @@ describe('AI 游戏创作 dev 端口', () => { username: 'alice', range: { start: 10000, end: 10099, label: '10000-10099' }, }), - findPort: async ({ preferredPort, portRange }) => { + findPort: async ({ preferredPort, portRange }: FindPortOptions) => { expect(preferredPort).toBe(10005); expect(portRange?.label).toBe('10000-10099'); return 10006; @@ -55,7 +118,9 @@ describe('AI 游戏创作 dev 端口', () => { }); test('父启动器选定端口后子启动器严格使用同一端口', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); await resolveAgcDevEndpoint({ env: { USER: 'alice', @@ -134,7 +199,9 @@ describe('AI 游戏创作 dev 端口', () => { describe('AGC 开发态后台 Web 端口', () => { test('Linux 使用用户端口段的 start + 3 槽位', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); const endpoint = await resolveAgcAdminWebEndpoint({ env: { USER: 'alice', LOGNAME: 'alice' }, platform: 'linux', @@ -161,7 +228,9 @@ describe('AGC 开发态后台 Web 端口', () => { }); test('AGC Vite 端口作为保留端口传入,避免被后台 Web 抢先占用', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); await resolveAgcAdminWebEndpoint({ env: {}, platform: 'win32', @@ -170,11 +239,13 @@ describe('AGC 开发态后台 Web 端口', () => { }); const [call] = findPort.mock.calls; - expect([...call[0].reservedPorts]).toEqual([10005]); + expect([...call![0].reservedPorts!]).toEqual([10005]); }); test('非 Linux 保留 3102 兼容优先端口并允许统一漂移', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort + 1); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort + 1, + ); const endpoint = await resolveAgcAdminWebEndpoint({ env: {}, platform: 'win32', @@ -189,7 +260,9 @@ describe('AGC 开发态后台 Web 端口', () => { }); test('显式 ADMIN_WEB_PORT 与脚本统一命名并支持严格占用', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); await resolveAgcAdminWebEndpoint({ env: { ADMIN_WEB_PORT: '3109' }, platform: 'win32', diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index 6cb5e3856..52c52c362 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -26,11 +26,15 @@ afterEach(() => { eventFake = null; }); -const ACTIVE_TURN = { +const ACTIVE_TURN: GameCreatorDirectActiveTurn = { projectPath: 'C:/projects/demo', - agentId: 'planning-agent-v2', - runId: 'run-1', -} as unknown as GameCreatorDirectActiveTurn; + projectName: '示例项目', + turnId: 'turn-1', + startedAt: 1, + status: 'running', + updatedAt: 2, + sequence: 1, +}; describe('useDirectActiveTurns', () => { it('keeps the snapshot identity when an event-triggered read returns the same content', async () => { @@ -56,7 +60,10 @@ describe('useDirectActiveTurns', () => { }); it('subscribes before the first snapshot and does not read while quiet', async () => { - const invoke = vi.fn(async () => [ACTIVE_TURN]); + const invoke = vi.fn< + [command: string], + Promise + >(async () => [ACTIVE_TURN]); eventFake = createTauriEventFake({ invoke }); eventFake.install(); const setIntervalSpy = vi.spyOn(window, 'setInterval'); @@ -235,7 +242,7 @@ describe('useDirectActiveTurns', () => { completeOld = resolve; }), ) - .mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]); + .mockResolvedValue([{ ...ACTIVE_TURN, turnId: 'new-run' }]); const { result, rerender } = renderHook( ({ enabled }) => useDirectActiveTurns({ invoke: invoke as never, enabled }), @@ -244,7 +251,7 @@ describe('useDirectActiveTurns', () => { rerender({ enabled: false }); rerender({ enabled: true }); await waitFor(() => - expect(result.current.activeTurns[0]?.runId).toBe('new-run'), + expect(result.current.activeTurns[0]?.turnId).toBe('new-run'), ); const current = result.current.activeTurns; await act(async () => { @@ -289,7 +296,7 @@ describe('ActiveProjectRunsPanel', () => { true, false, ]); - fireEvent.click(items[0]); + fireEvent.click(items[0]!); expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); }); diff --git a/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts index 3e620c89f..cff60d505 100644 --- a/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts +++ b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts @@ -95,7 +95,7 @@ describe('DirectProject 历史分页连拉', () => { expect(result.error).toBeNull(); const merged = selectDirectChatEntries( mergeDirectHistoryItems( - { turnRunning: false, history: existing, live: [] }, + { ...emptyDirectThreadChatState(), history: existing }, result.items, ), ); diff --git a/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx b/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx index 502a23058..70be0266c 100644 --- a/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx +++ b/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx @@ -24,6 +24,7 @@ test('运行中状态条的读秒按 100ms 刷新:不足一分钟的耗时以 vi.setSystemTime(STARTED_AT); const view = render( { try { render( & Partial, @@ -26,7 +24,15 @@ function withUserItemId( lifecycle: ThreadEvent, userItemId: string, ): ThreadEvent { - return { ...lifecycle, userItemId }; + // `userItemId` 只属于 `turn.started` / `turn.completed` 两种生命周期事件; + // 其余事件没有这个字段,原样返回。 + if ( + lifecycle.type === 'turn.started' || + lifecycle.type === 'turn.completed' + ) { + return { ...lifecycle, userItemId }; + } + return lifecycle; } /** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */ @@ -44,12 +50,17 @@ function toolStarted( } /** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */ -function toolOutput(): ThreadItem { +function toolOutput( + overrides: Partial< + Extract + > = {}, +): ThreadItem { return { itemType: 'function_call_output', itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', output: 'assets\ngame', at: 2000, + ...overrides, }; } @@ -295,7 +306,7 @@ describe('DirectProject 聊天 reducer', () => { id: `direct-codex:${clientTurnId}:user`, content: [{ type: 'input_text', text }], }, - } as Partial); + }); } it('按事件顺序折成队列,取消与放行都只按身份移除', () => { @@ -738,12 +749,12 @@ describe('DirectProject 聊天 reducer', () => { }); it('内部工具的阶段状态:开始事件就是运行中,正式完成事件才结束', () => { - const fileChange = { + const fileChange: Extract = { itemType: 'fileChange', itemId: 'file-1', changes: [{ path: 'game/src/hero.ts', kind: 'update' }], at: 0, - } as const; + }; const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'item.started', at: 1_000_100, item: fileChange }), ]); @@ -811,7 +822,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.started', at: 1_000_000 }), // 宿主收口的毫秒时间:原生回合时间是秒级精度,"上一轮结束之后又来一条开始事件" // 就是新回合,不能因为时间不比终点晚就把它当重放吞掉。 - event({ type: 'turn.completed', status: 'cancelled', at: 1_000_900 }), + event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }), ]); const next = reduceDirectThreadEvents(done, [ event({ type: 'turn.started', at: 1_000_000 }), diff --git a/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx b/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx index 91d0061a9..da10d1fc4 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx +++ b/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { useDirectThreadChatSubscription } from '../src/view/project-development/chat/controller/useDirectThreadChatSubscription'; import { createTauriEventFake, type TauriEventFake } from './tauriEventFake'; +import { resetTauriRuntime } from './tauriRuntimeFake'; const BOOTSTRAP = { subscriptionId: 'direct-subscription-1', @@ -17,8 +18,9 @@ afterEach(() => { cleanup(); eventFake?.restore(); eventFake = null; - delete window.__TAURI__; - delete window.__TAURI_INTERNALS__; + resetTauriRuntime(); + delete (window as Window & { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__; }); function installEventFake( diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index 7dbe863b7..e0e1dd3a7 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -369,9 +369,9 @@ describe('DirectProject 聊天分区', () => { // 「本轮结束于 … 」隐藏(不再出现 0.0 秒)。 expect(turns[1]?.users).toEqual([]); expect(turns[1]?.startedAt).toBe(0); - expect(turns[1]?.finals.map((block) => block.text)).toEqual([ - '待发消息已达上限(最多 5 条),请等前面几条发完再发送', - ]); + expect( + turns[1]?.finals.map((block) => ('text' in block ? block.text : '')), + ).toEqual(['待发消息已达上限(最多 5 条),请等前面几条发完再发送']); }); it('历史无用户条目时也保留一个回合承载正文', () => { diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index dc4e8e9c7..104ea9fc0 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -85,6 +85,7 @@ import { subscribeClientErrorEvents, } from '../src/services/errorReporting'; import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; +import { resetTauriRuntime } from './tauriRuntimeFake'; describe('客户端错误报告池', () => { afterEach(() => { @@ -94,12 +95,12 @@ describe('客户端错误报告池', () => { }); it('按 fingerprint 合并重复错误并累计次数', async () => { - const first = await captureClientError(new Error('重复错误'), { + const first = (await captureClientError(new Error('重复错误'), { source: 'test', - }); - const second = await captureClientError(new Error('重复错误'), { + }))!; + const second = (await captureClientError(new Error('重复错误'), { source: 'test', - }); + }))!; expect(second.eventId).toBe(first.eventId); expect(await getPendingClientErrorEvents()).toHaveLength(1); @@ -129,12 +130,12 @@ describe('客户端错误报告池', () => { }); it('提交成功后只清除本次提交的事件', async () => { - const first = await captureClientError(new Error('第一个错误'), { + const first = (await captureClientError(new Error('第一个错误'), { source: 'test', - }); - const second = await captureClientError(new Error('第二个错误'), { + }))!; + const second = (await captureClientError(new Error('第二个错误'), { source: 'test', - }); + }))!; await submitErrorReportBatch({ events: [first], logs: [] }); await markClientErrorEventsSubmitted([first]); @@ -143,9 +144,9 @@ describe('客户端错误报告池', () => { }); it('提交只把脱敏事件交给 Rust,不在渲染层携带凭据或 submissionId', async () => { - const event = await captureClientError(new Error('可重试错误'), { + const event = (await captureClientError(new Error('可重试错误'), { source: 'test', - }); + }))!; vi.mocked(invoke).mockImplementationOnce(async (command: string) => { if (command !== 'submit_error_report') { throw new Error(`unexpected invoke ${command}`); @@ -174,9 +175,9 @@ describe('客户端错误报告池', () => { it('本地 ack 失败时自动重试且不向调用方抛错', async () => { vi.useFakeTimers(); - const event = await captureClientError(new Error('ack 重试错误'), { + const event = (await captureClientError(new Error('ack 重试错误'), { source: 'test', - }); + }))!; vi.mocked(invoke) .mockRejectedValueOnce(new Error('暂时不可用')) .mockResolvedValueOnce(undefined); @@ -197,9 +198,9 @@ describe('客户端错误报告池', () => { }); it('登录态缺失时把 Rust 的稳定分类原样透出,不清除本地事件', async () => { - const event = await captureClientError(new Error('未登录错误'), { + const event = (await captureClientError(new Error('未登录错误'), { source: 'test', - }); + }))!; vi.mocked(invoke).mockRejectedValueOnce( new Error('authentication-required: 请先登录后再提交错误报告'), ); @@ -211,7 +212,10 @@ describe('客户端错误报告池', () => { }); it('解包 ClientAuthErrorWrapper,把载体序列化后的 message 原样上报,上下文只认显式入参', async () => { - const original = { type: 'authNetworkFailure', reason: 'unreachable' }; + const original: ClientAuthError = { + type: 'authNetworkFailure', + reason: 'unreachable', + }; await captureClientError(new ClientAuthErrorWrapper(original), { source: 'unhandledrejection', @@ -269,7 +273,7 @@ describe('客户端错误报告池', () => { }); it('浏览器预览中订阅错误事件不会产生未处理拒绝', async () => { - delete window.__TAURI__; + resetTauriRuntime(); const unsubscribe = subscribeClientErrorEvents(vi.fn()); unsubscribe(); diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts index a55e9fee9..5ae63e1af 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts @@ -20,7 +20,7 @@ import JSZip from 'jszip'; import { expect, test, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; -import type { StagedGamePackage } from '../src/app/types'; +import type { StagedGamePackage, TauriInvoke } from '../src/app/types'; import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload'; import { setClientServerSelection } from '../src/services/clientHttp'; import { publishLocalProjectGame } from '../src/services/gameDistributionPublish'; @@ -461,6 +461,7 @@ liveTest( } as unknown as GameCreationAppManifest; // 记录本次发布实际发送过的分片偏移,用来证明「中断后不重传已收字节」。 const sentOffsets: number[] = []; + // 该 mock 只按命令返回固定结构,无法满足 TauriInvoke 的泛型逐命令建模,故整体断言为 TauriInvoke。 const invoke = vi.fn( async (command: string, args?: Record) => { if (command !== 'publish_local_project_game') { @@ -544,7 +545,7 @@ liveTest( fileCount: staged.packageFileCount, }; }, - ); + ) as unknown as TauriInvoke; // 服务端要求发布必须带封面。真实 Tauri 里 `upload_platform_media_asset` 由 Rust // 完成「凭证 → 对象存储直传 → confirm」;jsdom 没有原生宿主,这里用同一个 // invoke 合同按服务端协议代跑,证明渲染层只提交字节和结构化元数据。 @@ -553,14 +554,17 @@ liveTest( assetKind: 'game_distribution_cover', pathSegments: ['game-distribution', 'cover', stamp], entityId: 'game-distribution-cover', - invoke: async (command, args) => { + invoke: async ( + command: string, + args?: Record, + ): Promise => { if (command !== 'upload_platform_media_asset') { throw new Error(`未预期的命令:${command}`); } - return await simulateNativePlatformAssetUpload( + return (await simulateNativePlatformAssetUpload( args as { input: Record }, token, - ); + )) as T; }, }); expect(uploadedCover.assetObjectId).toMatch(/\S/u); diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx index ddc3458b8..98de312bf 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx @@ -115,10 +115,11 @@ function renderPanel( beforeEach(() => { vi.mocked(readGameCoverGenerationPrice).mockImplementation( - () => new Promise(() => undefined), + // 永不落定的占位 Promise:用 never 兼容各返回类型,运行时行为不变。 + () => new Promise(() => undefined), ); vi.mocked(suggestGameDistributionPublishMetadata).mockImplementation( - () => new Promise(() => undefined), + () => new Promise(() => undefined), ); }); @@ -130,7 +131,9 @@ afterEach(() => { vi.mocked(suggestGameDistributionPublishMetadata).mockReset(); vi.mocked(uploadPlatformMediaAsset).mockReset(); delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; - window.localStorage.clear(); + // Node 26 下 vitest 的 jsdom 不再注入 localStorage(Node 全局 localStorage 为 undefined), + // 本用例也不读写它;这里只做存在性保护,避免清理阶段抛错。 + (window.localStorage as Storage | undefined)?.clear(); }); describe('GameDistributionPublishPanel', () => { @@ -201,8 +204,9 @@ describe('GameDistributionPublishPanel', () => { await waitFor(() => { expect(suggestGameDistributionPublishMetadata).toHaveBeenCalled(); }); + // waitFor 已确保至少调用过一次,这里用非空断言取出首次调用的入参。 const [request] = vi.mocked(suggestGameDistributionPublishMetadata).mock - .calls[0]; + .calls[0]!; const context = String(request.context ?? ''); expect(context.length).toBeGreaterThanOrEqual(5_000); expect(context.length).toBeLessThanOrEqual(6_000); diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx index 786c790a7..8bae3c725 100644 --- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx +++ b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx @@ -10,6 +10,7 @@ import { readFileSync } from 'node:fs'; import { beforeEach, describe, it, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { ProjectPermissionPolicyView } from '../src/app/types'; import { App, createGameCreationAppManifest, @@ -28,6 +29,7 @@ import { parseStyleSheet, resolveDeclarations, } from './styleCascade'; +import { installTauriRuntime } from './tauriRuntimeFake'; const readGamePublishAvailabilityMock = vi.hoisted(() => vi.fn(async () => true), @@ -77,7 +79,7 @@ function installTauri( options: { exportPackage?: () => unknown; manifest?: GameCreationAppManifest; - policy?: ReturnType; + policy?: ProjectPermissionPolicyView; readPolicy?: () => unknown; } = {}, ) { @@ -106,10 +108,10 @@ function installTauri( return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { - core: { invoke: invoke as never }, - event: { listen: chatHarness.listen as never }, - }; + installTauriRuntime({ + core: { invoke }, + event: { listen: chatHarness.listen }, + }); return { invoke }; } diff --git a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx index 34cea47b3..99325fff0 100644 --- a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx +++ b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx @@ -8,10 +8,11 @@ import { HOME_WEB_PREFLIGHT_IPC_FAILURE, } from '../src/features/app-shell/homeWebPreflight'; import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); }); function mount(preflight: () => Promise) { @@ -31,13 +32,12 @@ function mount(preflight: () => Promise) { return { activeRuntime: 'design' }; return null; }); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); const hook = renderHook(() => useHomeProjectCreation({ setStatus: vi.fn(), showTransientNotice: vi.fn(), setLauncherView: vi.fn(), - setAgentChatProjectPath: vi.fn(), rememberRecentWorkspace: vi.fn(), }), ); diff --git a/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx b/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx index a012f2d60..3cb629c5a 100644 --- a/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx +++ b/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx @@ -9,9 +9,10 @@ import { import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { SpriteAsset } from '../src/features/ui-editor/types/SpriteAsset'; import { ImageAssetSelector } from '../src/view/ui-editor/components/Inspector/Components/ImageAssetSelector'; -const sprites = { +const sprites: Record = { hero: { asset_id: 'hero', metadata: { name: '主角', asset_type: 'sprite' }, diff --git a/apps/ai-game-creator-shell/tests/launcherNoticeShellWiring.test.tsx b/apps/ai-game-creator-shell/tests/launcherNoticeShellWiring.test.tsx index 1b75163a8..6741e4ea3 100644 --- a/apps/ai-game-creator-shell/tests/launcherNoticeShellWiring.test.tsx +++ b/apps/ai-game-creator-shell/tests/launcherNoticeShellWiring.test.tsx @@ -26,6 +26,7 @@ import { pickProjectFromLauncher, testAuthUser, } from './appSurface/harness'; +import { installTauriRuntime } from './tauriRuntimeFake'; const PROJECT_PATH = '/tmp/run-notice-shell-project'; @@ -115,10 +116,10 @@ function renderWorkbench() { return runtimeHarness.invoke(command, args); }, ); - window.__TAURI__ = { - core: { invoke: invoke as never }, - event: { listen: runtimeHarness.listen as never }, - }; + installTauriRuntime({ + core: { invoke }, + event: { listen: runtimeHarness.listen }, + }); render( { } return runtimeHarness.invoke(command); }); - window.__TAURI__ = { - core: { invoke: invoke as never }, - event: { listen: runtimeHarness.listen as never }, - }; + installTauriRuntime({ + core: { invoke }, + event: { listen: runtimeHarness.listen }, + }); render( { }; const oldChildRect = resolvePageRect(child.layout.transform, oldParentRect); - const [{ transform }] = resolveChildrenTransformsForParentRect( + const [resolvedChild] = resolveChildrenTransformsForParentRect( [child], oldParentRect, newParentRect, ); - expect(resolvePageRect(transform, newParentRect)).toEqual(oldChildRect); + expect(resolvePageRect(resolvedChild!.transform, newParentRect)).toEqual( + oldChildRect, + ); }); it('keeps the page rectangle stable when inspector anchors change', () => { diff --git a/apps/ai-game-creator-shell/tests/platformSession.test.ts b/apps/ai-game-creator-shell/tests/platformSession.test.ts index 10734765c..29273e89e 100644 --- a/apps/ai-game-creator-shell/tests/platformSession.test.ts +++ b/apps/ai-game-creator-shell/tests/platformSession.test.ts @@ -8,6 +8,8 @@ */ import { beforeEach, expect, test, vi } from 'vitest'; +import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; + const refreshClientAuthSession = vi.hoisted(() => vi.fn()); vi.mock('../src/services/clientAuth', () => ({ @@ -24,7 +26,7 @@ import { subscribePlatformSessionGeneration, } from '../src/services/platformSession'; -const userA = { +const userA: AuthUser = { id: 'user-a', publicUserCode: 'tn-a', displayName: '账号 A', diff --git a/apps/ai-game-creator-shell/tests/pluginHost.test.ts b/apps/ai-game-creator-shell/tests/pluginHost.test.ts index 416ffeca6..66f3a9afa 100644 --- a/apps/ai-game-creator-shell/tests/pluginHost.test.ts +++ b/apps/ai-game-creator-shell/tests/pluginHost.test.ts @@ -5,7 +5,9 @@ import { startAvailableAgcEditorPlugins, } from '../src/services/pluginHost'; -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + vi.unstubAllGlobals(); +}); const editorPlugins = [ { id: 'agc-cocos-editor', name: 'Cocos Creator' }, diff --git a/apps/ai-game-creator-shell/tests/previewActivation.test.tsx b/apps/ai-game-creator-shell/tests/previewActivation.test.tsx index 68a7af95a..8816daaac 100644 --- a/apps/ai-game-creator-shell/tests/previewActivation.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewActivation.test.tsx @@ -32,6 +32,7 @@ import { screen, waitFor, } from './appSurface/harness'; +import { installTauriRuntime } from './tauriRuntimeFake'; const PROJECT_PATH = '/tmp/preview-activation-project'; const PROJECT_ID = 'preview-activation-project'; @@ -75,10 +76,10 @@ function installTauri( return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { - core: { invoke: invoke as never }, - event: { listen: chatHarness.listen as never }, - }; + installTauriRuntime({ + core: { invoke }, + event: { listen: chatHarness.listen }, + }); return { invoke, invokeCounts }; } diff --git a/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx b/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx index aab60e92a..2773dec5f 100644 --- a/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx @@ -60,12 +60,6 @@ function node( } const activeImage: UIDesignImage = { - metadata: { - name: '测试界面', - description: '', - role: null, - slave_to: null, - }, path: 'assets/page.png', pixel_size: [1200, 800], pixels_per_unit: 1, @@ -102,6 +96,7 @@ function createCanvas( sprites: {}, fontFaces: {}, tree: { src_ui_design: 'page-1', root }, + selectedTreeId: null, selectedNode: null, selectedNodeId: null, keepChildrenUnchanged: false, @@ -240,7 +235,9 @@ describe('PreviewWorkspace tree drag', () => { pointerId: 11, }); expect(committed.canvas.setTreeOffset).toHaveBeenCalledTimes(1); - expect(committed.canvas.setTreeOffset.mock.calls[0]?.[0]).toBe('page-1'); + expect(vi.mocked(committed.canvas.setTreeOffset).mock.calls[0]?.[0]).toBe( + 'page-1', + ); cleanup(); const cancelled = renderTreeDrag(); diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx index 6570fb463..a302621da 100644 --- a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx @@ -70,6 +70,7 @@ function createCanvas( sprites: {}, fontFaces: {}, tree: { src_ui_design: 'page-1', root }, + selectedTreeId: null, selectedNode: null, selectedNodeId: null, keepChildrenUnchanged: false, @@ -85,6 +86,7 @@ function createCanvas( insertNode: vi.fn(), insertNodeAfter: vi.fn(), deleteNode: vi.fn(), + setTreeOffset: vi.fn(), openClearDialog: vi.fn(), ...overrides, }; @@ -136,7 +138,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('applies the adaptive screen-space spacing to the preview background', () => { - const rendered = render(); + const rendered = render( + , + ); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); const scale = logicalViewportScale(rendered.container); const [backgroundWidth] = preview.style.backgroundSize.split('px'); @@ -147,7 +156,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('uses the toolbar zoom step while the preview is hovered', () => { - const rendered = render(); + const rendered = render( + , + ); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); const before = logicalViewportScale(rendered.container); @@ -164,7 +180,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('requires hover or focus and ignores interactive targets', () => { - const rendered = render(); + const rendered = render( + , + ); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); const zoomButton = screen.getByRole('button', { name: '放大画布' }); const initial = logicalViewportScale(rendered.container); @@ -202,7 +225,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('keeps the actual-size shortcut within the preview scope', () => { - const rendered = render(); + const rendered = render( + , + ); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); fireEvent.focus(preview); @@ -215,7 +245,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('refits the preview when the zoom percentage button is clicked', () => { - const rendered = render(); + const rendered = render( + , + ); const fitButton = screen.getByRole('button', { name: '适配画布' }); const zoomInButton = screen.getByRole('button', { name: '放大画布' }); const initial = logicalViewportScale(rendered.container); @@ -237,6 +274,9 @@ describe('PreviewWorkspace quick zoom', () => { activeImageId: null, tree: null, })} + showFrame={false} + showOriginImage={false} + showComponent={true} />, ); @@ -268,12 +308,24 @@ describe('PreviewWorkspace quick zoom', () => { }, }; - const single = render(); + const single = render( + , + ); const singleScale = logicalViewportScale(single.container); single.unmount(); const joint = render( - , + , ); const jointScale = logicalViewportScale(joint.container); // 联合边界(两棵树横跨 1600 单位)明显比单棵树宽:首次进入必须适配全部树。 @@ -283,6 +335,9 @@ describe('PreviewWorkspace quick zoom', () => { joint.rerender( , ); expect(logicalViewportScale(joint.container)).toBe(jointScale); @@ -303,6 +358,9 @@ describe('PreviewWorkspace quick zoom', () => { canvas={createCanvas({ uiTrees: [{ src_ui_design: 'page-1', root: movedRoot }], })} + showFrame={false} + showOriginImage={false} + showComponent={true} />, ); diff --git a/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts b/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts index 0a80c86b2..327a742cc 100644 --- a/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts +++ b/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts @@ -67,7 +67,7 @@ describe('process-session real E2E fixture', () => { return new Promise((resolve, reject) => { const waiter = { predicate, - resolve: (line) => { + resolve: (line: string) => { clearTimeout(timer); waiters.delete(waiter); resolve(line); diff --git a/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts b/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts index 3b5fbfa05..d3551d9d4 100644 --- a/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts +++ b/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts @@ -9,11 +9,12 @@ import { resetWorkspacePreferencesForTests, setProjectCreationDirectory, } from '../src/features/app-shell/workspacePreferences'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; describe('项目创建目录偏好', () => { beforeEach(() => { resetWorkspacePreferencesForTests(); - delete window.__TAURI__; + resetTauriRuntime(); }); it('去掉首尾空白与多余分隔符,并保留盘根', () => { @@ -81,7 +82,7 @@ describe('项目创建目录偏好', () => { } throw new Error('unexpected invoke ' + command); }); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: vi.fn(async (_event, handler) => { @@ -91,7 +92,7 @@ describe('项目创建目录偏好', () => { }; }), }, - }; + }); const { result } = renderHook(() => useProjectCreationDirectory()); await waitFor(() => { diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 574229d91..0cb0a8e0f 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -2,7 +2,11 @@ import React, { useState } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetKind, + GameCreationAppAssetManifestEntry, + GameCreationAppManifest, +} from '../../../packages/shared/src/contracts/gameCreationApp'; const canvasFixture = vi.hoisted(() => ({ manifest: null as GameCreationAppManifest | null, @@ -29,9 +33,20 @@ import { waitFor, within, } from './appSurface/harness'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const projectPath = '/tmp/live-canvas-integration'; +/** + * 历史夹具沿用旧 kind `art-image` / `game-rules`,它们已不在当前 + * `GameCreationAppAssetKind` 联合里;用例依赖「未识别 kind」的运行时语义, + * 因此保留原字符串,只在类型层收口。 + */ +function legacyAssetKind(kind: string): GameCreationAppAssetKind { + // 旧 kind 字面量与当前联合结构上不可兼容,只能显式转换以保留原串的运行时语义。 + return kind as unknown as GameCreationAppAssetKind; +} + /** * 切栏目走「资源总览」的栏目缩略卡片(左侧大纲导航已删除)。 * 与两个 appSurface suite 里的同名助手同口径:切完必须落到目标栏目的子画布。 @@ -183,7 +198,7 @@ function LiveWorkbench() { initial.assets = [ { id: 'source-art', - kind: 'art-image', + kind: legacyAssetKind('art-image'), mediaType: 'image/png', localPath: 'assets/source-art.png', source: { kind: 'canvas', taskId: null, resourceId: 'source-resource' }, @@ -279,7 +294,7 @@ function DerivedWorkbench({ initial.assets = [ { id: 'source-rules', - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, @@ -288,11 +303,11 @@ function DerivedWorkbench({ ? [ { id: 'source-art', - kind: 'art-image', + kind: legacyAssetKind('art-image'), mediaType: 'image/png', localPath: `assets/${artName}`, source: { kind: 'generated', resourceId: 'art-resource' }, - }, + } satisfies GameCreationAppAssetManifestEntry, ] : []), ...(includeCharacter @@ -304,7 +319,7 @@ function DerivedWorkbench({ mediaType: 'image/png', localPath: `assets/${characterName}`, source: { kind: 'generated', resourceId: 'hero-resource' }, - }, + } satisfies GameCreationAppAssetManifestEntry, ] : []), ]; @@ -415,27 +430,33 @@ function MixedCategoryWorkbench() { '实时画布项目', ); initial.assets = [ - ...Array.from({ length: 6 }, (_, index) => ({ - id: `asset-doc-${index + 1}`, - kind: 'document', - category: 'document' as const, - mediaType: 'text/markdown', - localPath: `docs/note-0${index + 1}.md`, - source: { - kind: 'generated' as const, - resourceId: `doc-resource-${index + 1}`, - }, - })), - ...Array.from({ length: 6 }, (_, index) => ({ - id: `asset-image-${index + 1}`, - kind: 'art-image', - mediaType: 'image/png', - localPath: `assets/img-0${index + 1}.png`, - source: { - kind: 'generated' as const, - resourceId: `image-resource-${index + 1}`, - }, - })), + ...Array.from( + { length: 6 }, + (_, index): GameCreationAppAssetManifestEntry => ({ + id: `asset-doc-${index + 1}`, + kind: 'document', + category: 'document' as const, + mediaType: 'text/markdown', + localPath: `docs/note-0${index + 1}.md`, + source: { + kind: 'generated' as const, + resourceId: `doc-resource-${index + 1}`, + }, + }), + ), + ...Array.from( + { length: 6 }, + (_, index): GameCreationAppAssetManifestEntry => ({ + id: `asset-image-${index + 1}`, + kind: legacyAssetKind('art-image'), + mediaType: 'image/png', + localPath: `assets/img-0${index + 1}.png`, + source: { + kind: 'generated' as const, + resourceId: `image-resource-${index + 1}`, + }, + }), + ), ]; const [manifest, setManifest] = useState(initial); canvasFixture.manifest = manifest; @@ -470,7 +491,7 @@ function SwitchableDerivedWorkbench({ manifest.assets = [ { id: 'source-rules', - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, @@ -512,7 +533,7 @@ function SwitchableDerivedWorkbench({ describe('project resource live canvas integration', () => { afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); canvasFixture.manifest = null; canvasFixture.revision = 0; }); @@ -656,7 +677,7 @@ describe('project resource live canvas integration', () => { ); const asset = { id: `edit-${operationId}`, - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: `assets/edits/${operationId}-recovered-rules.md`, source: { @@ -732,7 +753,7 @@ describe('project resource live canvas integration', () => { const assetId = `edit-${operationId}`; const asset = { id: assetId, - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: `assets/edits/${operationId}-rules.md`, source: { @@ -806,10 +827,10 @@ describe('project resource live canvas integration', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: async () => () => undefined }, - }; + }); return { invoke, deriveCalls, @@ -1348,7 +1369,7 @@ describe('project resource live canvas integration', () => { ); } const base = canvasFixture.manifest!; - const asset = { + const asset: GameCreationAppAssetManifestEntry = { id: `edit-${operation.operationId}`, kind: 'video', mediaType: 'video/mp4', @@ -1619,7 +1640,7 @@ describe('project resource live canvas integration', () => { ); const staleAsset = { id: 'stale-recovered-asset', - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: 'assets/stale-recovered-rules.md', source: { @@ -1773,11 +1794,11 @@ describe('project resource live canvas integration', () => { ); const actions = screen.getAllByRole('button', { name: '继续原编辑' }); - fireEvent.click(actions[0]); - fireEvent.click(actions[0]); - expect(actions[0].hasAttribute('disabled')).toBe(true); - expect(actions[1].hasAttribute('disabled')).toBe(false); - fireEvent.click(actions[1]); + fireEvent.click(actions[0]!); + fireEvent.click(actions[0]!); + expect(actions[0]!.hasAttribute('disabled')).toBe(true); + expect(actions[1]!.hasAttribute('disabled')).toBe(false); + fireEvent.click(actions[1]!); await waitFor(() => expect(resumeCalls).toHaveLength(2)); expect(resumeCalls.map((call) => call.operationId)).toEqual([ @@ -1825,8 +1846,8 @@ describe('project resource live canvas integration', () => { }), ); const actions = screen.getAllByRole('button', { name: '继续原编辑' }); - fireEvent.click(actions[0]); - fireEvent.click(actions[1]); + fireEvent.click(actions[0]!); + fireEvent.click(actions[1]!); await act(async () => { firstResume.reject(new Error('并行恢复一失败')); @@ -1894,7 +1915,7 @@ describe('project resource live canvas integration', () => { const base = canvasFixture.manifest!; const asset = { id: 'reopened-dialog-output', - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: 'assets/reopened-dialog-output.md', source: { diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts index 08ff3209a..d9e4ee0f5 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts @@ -411,7 +411,10 @@ describe('素材上传后的清单快照必须配对', () => { revision: 3, manifest: manifest('project-live', []), }; - const invoke = async (command: string): Promise => { + const invoke = async ( + command: string, + _args?: Record, + ): Promise => { switch (command) { case 'upload_local_asset': if (advanceOnUpload) { diff --git a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts index 829ed15c0..4a38324a9 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts @@ -45,7 +45,9 @@ describe('项目资源投影', () => { }, { id: 'registered-bgm', - kind: 'bgm', + // `bgm` 已不在 canonical kind 词表里,运行时会收口成 `unknown`(落「待归类」); + // 这里直接写真实 union 成员,保持投影结果不变。 + kind: 'unknown', mediaType: 'audio/wav', localPath: 'audio/registered-bgm.wav', source: { kind: 'generated' }, @@ -59,7 +61,8 @@ describe('项目资源投影', () => { }, { id: 'unknown-binary', - kind: 'binary', + // 真实词表用 `unknown` 表示无法解析的二进制产物,替换已退役的 `binary`。 + kind: 'unknown', mediaType: 'application/octet-stream', localPath: 'build/game.bundle', source: { kind: 'generated' }, diff --git a/apps/ai-game-creator-shell/tests/projectSnapshotWorkspace.test.tsx b/apps/ai-game-creator-shell/tests/projectSnapshotWorkspace.test.tsx index 88d12ef53..63f651063 100644 --- a/apps/ai-game-creator-shell/tests/projectSnapshotWorkspace.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectSnapshotWorkspace.test.tsx @@ -5,16 +5,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { useProjectSnapshotWorkspace } from '../src/features/app-shell/useProjectSnapshotWorkspace'; import { createProjectSnapshotWorkspaceRegistration } from '../src/services/projectSnapshotWorkspace'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); }); describe('项目快照窗口登记', () => { it('在无 projectPath URL 的单窗口登记、切换、离开,StrictMode 不误关项目', async () => { const invoke = vi.fn(async () => undefined); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); function Workspace({ path }: { path: string | null }) { useProjectSnapshotWorkspace(path); return null; diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx index 1bbb6d1d7..f94eebda4 100644 --- a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -3,6 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { useRecentProjects } from '../src/features/app-shell/useRecentProjects'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const READY_PROJECT = { projectPath: '/tmp/ready-project', @@ -21,7 +22,7 @@ const READY_PROJECT = { }; afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); window.localStorage.clear(); }); @@ -47,7 +48,7 @@ test('刷新坏项目时保留其它项目已确认的正常状态', async () => return READY_PROJECT; }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/ready-project']), @@ -106,7 +107,7 @@ test('单次目录检查失败会就地重试,不会把整行钉成「检查 return READY_PROJECT; }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/ready-project']), @@ -148,7 +149,7 @@ test('失败结果不跨轮保留:刷新时该项回到「检查中」并重 return { ...READY_PROJECT, projectPath }; }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/broken-project']), @@ -193,7 +194,7 @@ test('提权类失败不重试:不放大 UAC 弹窗', async () => { ); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/elevation-project']), @@ -238,7 +239,7 @@ test('Rust 偏好事件与 command 返回同时到达时只检查新增项目一 throw new Error('unexpected invoke ' + command); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: vi.fn(async (_event, handler) => { @@ -248,7 +249,7 @@ test('Rust 偏好事件与 command 返回同时到达时只检查新增项目一 }; }), }, - }; + }); const { result } = renderHook(() => useRecentProjects(vi.fn())); await waitFor(() => { @@ -282,7 +283,7 @@ test('Rust 偏好事件与 command 返回同时到达时只检查新增项目一 ); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/denied-elevation-project']), diff --git a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts index 05a19b7b4..98ce0cd79 100644 --- a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts +++ b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts @@ -19,6 +19,7 @@ import { type RuntimeRegionReference, } from '../src/features/project-workspace/resourceReferences'; import type { UserContentPart } from '../src/view/project-development/chat/generated/UserContentPart'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; function asset( id: string, @@ -67,7 +68,7 @@ const attachmentPart: UserContentPart = { afterEach(() => { cleanup(); vi.restoreAllMocks(); - window.__TAURI__ = undefined; + resetTauriRuntime(); }); describe('资源引用 provider', () => { @@ -292,7 +293,7 @@ describe('附件 provider', () => { describe('运行画面区域 provider', () => { it('静默且只认运行画面区域 part,可选字段缺失时收成 undefined', () => { expect(runtimeRegionReferenceProvider.trigger).toBe(null); - expect(runtimeRegionReferenceProvider.match).toBeUndefined(); + expect(runtimeRegionReferenceProvider.fuzzyLookup).toBeUndefined(); expect( runtimeRegionReferenceProvider.toReference(runtimeRegionPart), ).toEqual({ @@ -368,7 +369,7 @@ describe('Skill provider', () => { if (command === 'list_client_extensions') return []; throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useSkillReferenceProvider()); expect(result.current.trigger).toBe('$'); @@ -420,7 +421,7 @@ describe('Skill provider', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useSkillReferenceProvider()); act(() => { @@ -450,7 +451,7 @@ describe('Skill provider', () => { if (command === 'list_client_extensions') return []; throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useSkillReferenceProvider()); @@ -511,7 +512,7 @@ describe('Skill provider', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useSkillReferenceProvider()); act(() => { @@ -542,7 +543,7 @@ describe('Skill provider', () => { throw new Error(`unexpected invoke ${command}`); }) .mockRejectedValueOnce(new Error('transient')); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useSkillReferenceProvider()); act(() => { @@ -568,7 +569,7 @@ describe('Skill provider', () => { it('`toReference` / `refresh` / `mentionToken` 只认 Skill', async () => { const invoke = vi.fn(async () => []); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useSkillReferenceProvider()); expect( diff --git a/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx index c1d0863b7..3598255aa 100644 --- a/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx @@ -17,6 +17,7 @@ import { waitFor, within, } from './appSurface/harness'; +import { installTauriRuntime } from './tauriRuntimeFake'; const PROJECT_PATH = '/tmp/batch-tags-project'; const PROJECT_ID = 'batch-tags-project'; @@ -158,7 +159,7 @@ function BatchTagsHost({ recentRunStopReason={null} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} - supervisor={
} + chat={
} onManifestChange={(path, next) => { manifestChanges.push({ path, projectId: next.projectId }); // 壳按当前项目身份收敛:只有当前项目的回写才落到工作台状态里。 @@ -294,10 +295,10 @@ function installHostTauri( throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { - core: { invoke: invoke as never }, - event: { listen: (async () => () => undefined) as never }, - }; + installTauriRuntime({ + core: { invoke }, + event: { listen: async () => () => undefined }, + }); return { invoke, batchWrites, diff --git a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts index e2099510f..32d4dab3c 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts @@ -85,7 +85,7 @@ describe('resourceBookOverviewCardLayout', () => { }); describe('selectResourceBookOverviewCards', () => { - const cards = ['a', 'b', 'c', 'd'].map(resource); + const cards = ['a', 'b', 'c', 'd'].map((id) => resource(id)); const positions = new Map([ ['a', { x: 0, y: 0 }], ['b', { x: 1_000, y: 0 }], @@ -147,7 +147,9 @@ describe('groupResourceBookStacks', () => { }); describe('buildResourceBookScenePlan', () => { - const resources = ['art-0', 'art-1', 'art-2', 'art-3'].map(resource); + const resources = ['art-0', 'art-1', 'art-2', 'art-3'].map((id) => + resource(id), + ); const positions = new Map( resources.map((item, index) => [item.id, { x: index * 200, y: 0 }]), ); @@ -156,12 +158,20 @@ describe('buildResourceBookScenePlan', () => { ); const base = { visibleCategoryOrder: ['unclassified'] as const, - resourcesByCategory: new Map([['unclassified', resources]]), - overviewRects: new Map([['unclassified', rect]]), + // 显式标注 key 为 ResourceBookTarget:否则 new Map 会推成 Map, + // 与 buildResourceBookScenePlan 的 ReadonlyMap 不兼容。 + resourcesByCategory: new Map([ + ['unclassified', resources], + ]), + overviewRects: new Map([ + ['unclassified', rect], + ]), positions, cardSizes, visibleResourceIds: new Set(resources.map((item) => item.id)), - categoryViewCenters: new Map([['unclassified', { x: 0, y: 64 }]]), + categoryViewCenters: new Map([ + ['unclassified', { x: 0, y: 64 }], + ]), cardsReady: true, }; @@ -197,11 +207,11 @@ describe('buildResourceBookScenePlan', () => { const plan = buildResourceBookScenePlan({ ...base, visibleCategoryOrder: ['unclassified', 'scene'], - resourcesByCategory: new Map([ + resourcesByCategory: new Map([ ['unclassified', resources], ['scene', [resource('code-0')]], ]), - overviewRects: new Map([ + overviewRects: new Map([ ['unclassified', rect], ['scene', { ...rect, left: 500 }], ]), @@ -335,7 +345,10 @@ describe('buildResourceBookScenePlan', () => { positions: allPositions, cardSizes: allCardSizes, visibleResourceIds: new Set(allResources.map((item) => item.id)), - categoryViewCenters: new Map([ + categoryViewCenters: new Map< + ResourceBookTarget, + { x: number; y: number } + >([ ['unclassified', { x: 0, y: 64 }], ['document', { x: 900, y: 64 }], ]), @@ -998,7 +1011,7 @@ describe('resourceBookAllLayoutKey', () => { categoryOrder: ['document', 'unclassified'] as const, resourcesByCategory: new Map([ ['document', [documentResource(1)]], - ['unclassified', [imageAssetResource(1, { width: 180, height: 180 })]], + ['unclassified', [imageAssetResource(1)]], ]), cardSizes: new Map([ ['agent-result:design:run-1', { width: 180, height: 128 }], diff --git a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts index 356c85f08..e65608448 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts @@ -147,7 +147,7 @@ describe('resource book card presentation', () => { expect(resourceBookCategoryCardPresentation(entering, 'scene')).toBe( 'child', ); - expect(resourceBookCategoryCardPresentation(entering, 'code')).toBe( + expect(resourceBookCategoryCardPresentation(entering, 'audio')).toBe( 'overview', ); }); @@ -163,7 +163,7 @@ describe('resource book card presentation', () => { ); expect(resourceBookCategoryCardPresentation(steady, 'scene')).toBe('child'); - expect(resourceBookCategoryCardPresentation(steady, 'code')).toBeNull(); + expect(resourceBookCategoryCardPresentation(steady, 'audio')).toBeNull(); }); it('returns every category to the stack and exposes the exiting window', () => { @@ -179,7 +179,7 @@ describe('resource book card presentation', () => { expect(resourceBookCategoryCardPresentation(returning, 'scene')).toBe( 'overview', ); - expect(resourceBookCategoryCardPresentation(returning, 'code')).toBe( + expect(resourceBookCategoryCardPresentation(returning, 'audio')).toBe( 'overview', ); expect(resourceBookShowsExitingCards(returning)).toBe(true); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index da51d6641..89b49797d 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, vi } from 'vitest'; +import type { GameCreationAppAssetKind } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createResourceCanvasAssetGenerationQueue, mergeResourceCanvasAssetGenerationTasksWithRecords, @@ -21,6 +22,15 @@ import { } from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; +/** + * 未识别 kind 字面量(如 `nope`)不在当前 `GameCreationAppAssetKind` 联合里;本用例依赖 + * 「未识别 kind 不编造文案」的运行时语义,因此保留原始字符串,只在类型层收口。 + */ +function unknownAssetKind(kind: string): GameCreationAppAssetKind { + // 未识别 kind 与当前联合结构上不可兼容,这里显式转换以保留原始串的运行时语义。 + return kind as unknown as GameCreationAppAssetKind; +} + const uiPrototypeAction: ResourceCanvasAssetToolAction = { id: 'generate-ui-prototype', route: 'asset', @@ -205,7 +215,9 @@ describe('生成任务模型', () => { '生成 UI 设计图', ); expect(resourceCanvasAssetGenerationKindLabel('image')).toBe('生成图片'); - expect(resourceCanvasAssetGenerationKindLabel('nope')).toBeNull(); + expect( + resourceCanvasAssetGenerationKindLabel(unknownAssetKind('nope')), + ).toBeNull(); }); test('已耗时文案按分秒呈现', () => { @@ -311,7 +323,7 @@ describe('本地排队驱动器', () => { const harness = createHarness(); let notify: (() => void) | null = null; let releaseCount = 0; - const wait = vi.fn(harness.deps.wait); + const wait = vi.fn(harness.deps.wait!); harness.deps.wait = wait; harness.deps.subscribe = async (_projectPath, _taskId, onChanged) => { notify = onChanged; @@ -328,7 +340,8 @@ describe('本地排队驱动器', () => { expect(wait).not.toHaveBeenCalled(); harness.advance('task-event', 'completed'); - notify?.(); + // 订阅回调在闭包里赋值,控制流分析仍把 notify 收窄为 null,这里显式放宽回声明类型。 + (notify as (() => void) | null)?.(); await settled; expect(wait).not.toHaveBeenCalled(); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx index 22dfdb814..e0960a072 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx @@ -9,7 +9,10 @@ import { import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetKind, + GameCreationAppAssetManifestEntry, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import { type ChatReference, directCodexContentToPromptText, @@ -89,7 +92,7 @@ function asset( id: string, mediaType: string, localPath: string, - kind = 'image', + kind: GameCreationAppAssetKind = 'image', ): GameCreationAppAssetManifestEntry { return { id, kind, mediaType, localPath, source: { kind: 'canvas' } }; } @@ -253,8 +256,10 @@ describe('参考图模型', () => { describe('生成面板的参考接线', () => { test('图片入口呈现参考计数与引用输入区,提交带回引用', async () => { const user = userEvent.setup(); - const onSubmit = - vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const onSubmit = vi.fn< + [input: ResourceCanvasAssetGenerationSubmitInput], + void + >(); const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); const references = [resourceReferenceFromAsset(imageAsset, 'asset-picker')]; render( @@ -305,8 +310,10 @@ describe('生成面板的参考接线', () => { */ test('图标规范入口呈现参考计数与引用输入区,提交带回引用', async () => { const user = userEvent.setup(); - const onSubmit = - vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const onSubmit = vi.fn< + [input: ResourceCanvasAssetGenerationSubmitInput], + void + >(); const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); const references = [resourceReferenceFromAsset(imageAsset, 'asset-picker')]; render( @@ -422,8 +429,10 @@ describe('生成面板的参考接线', () => { test('图集入口不呈现参考选择,只提交纯提示词', async () => { const user = userEvent.setup(); - const onSubmit = - vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const onSubmit = vi.fn< + [input: ResourceCanvasAssetGenerationSubmitInput], + void + >(); render( { test('超限时提交被挡住并给出原因', async () => { const user = userEvent.setup(); - const onSubmit = - vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const onSubmit = vi.fn< + [input: ResourceCanvasAssetGenerationSubmitInput], + void + >(); render( cssLengthToPx(term)) .reduce((total, term) => total + term, 0); @@ -270,9 +270,12 @@ describe('「生成任务」侧栏样式', () => { */ const titlebarMinHeight = cssLengthToPx( declaration( - resolveDeclarations(globalWidthRules, [ - '.game-resource-book-scene-titlebar', - ]), + resolveDeclarations( + globalWidthRules, + ['.game-resource-book-scene-titlebar'], + // 标题栏 min-height 不在宽度媒体查询里,取与本套件其余断言一致的桌面宽度。 + 1280, + ), 'min-height', ), ); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx index cb7c5f6f2..04ef0a349 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx @@ -8,6 +8,7 @@ import { } from '@testing-library/react'; import { afterEach, describe, expect, test, vi } from 'vitest'; +import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; import { EDITOR_IMAGE_DIMENSION_OPTIONS, IMAGE_MODEL_NANOBANANA2, @@ -197,7 +198,10 @@ describe('resourceCanvasBottomToolbarModel', () => { }); test('权威规范图判据与 Rust 同口径:精确落点 + icon-spec + 画布来源', () => { - const canvasIconSpec = { + const canvasIconSpec: Pick< + GameCreationAppAssetManifestEntry, + 'kind' | 'localPath' | 'mediaType' | 'source' + > = { kind: 'icon-spec', localPath: RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH, mediaType: 'image/png', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx index dad22664f..06a934d0c 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx @@ -22,8 +22,9 @@ vi.mock('@tauri-apps/api/core', async () => ({ ...(await vi.importActual( '@tauri-apps/api/core', )), + // `core` / `invoke` 在全局声明里都是可选:这里断言它们存在。 invoke: (command: string, args?: Record) => - window.__TAURI__!.core.invoke(command, args), + window.__TAURI__!.core!.invoke!(command, args), })); const PROJECT_PATH = '/tmp/workbench-card-name'; @@ -58,7 +59,9 @@ function createCardNameManifest(): GameCreationAppManifest { }, { id: 'asset-notes', - kind: 'design-document', + // 旧夹具用非法 kind 'design-document' 表达「无法归类」;canonical 词表里 + // 与之等价的是 unknown(同样派生到「待归类」),mediaType 仍让它走文档卡。 + kind: 'unknown', mediaType: 'text/markdown', localPath: 'memory/设计文档.md', source: { kind: 'generated' }, @@ -228,7 +231,7 @@ function renderWorkbench(manifest: GameCreationAppManifest) { attachments: [], recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx index 446f721ee..e62f08807 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx @@ -11,16 +11,26 @@ import type { import { type ChatReference, RESOURCE_REFERENCE_INSERT_MANY_EVENT, + type ResourceReference, type ResourceReferenceInsertManyEventDetail, } from '../src/features/project-workspace/resourceReferences'; import ProjectDevelopmentView from '../src/view/project-development'; +import type { ProjectAttachmentResult } from '../src/view/project-development/resourceProjectionModel'; import { + App, createGameCreationAppManifest, + createProjectChatRuntimeHarness, fireEvent, React, render, screen, } from './appSurface/harness'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; + +// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。 +const AppComponent = App as React.ComponentType< + NonNullable> +>; /** * 「拖动素材到对话 = 批量 @ 引用」的行为级验收。 @@ -87,10 +97,21 @@ type LayoutWrite = { positions: ProjectResourceCanvasPosition[]; }; -function installTauri(): { layoutWrites: LayoutWrite[] } { +/** 画布与真实 DirectProject 聊天面共用的项目身份。 */ +const CANVAS_PROJECT_PATH = '/tmp/chat-reference-drop'; + +function installTauri(manifest: GameCreationAppManifest): { + layoutWrites: LayoutWrite[]; +} { const layoutWrites: LayoutWrite[] = []; const persisted = new Map(); const revisions = new Map(); + // 聊天面是**真的** DirectProject:它自己的清单 / 订阅 / 历史 IPC 全部由这份运行时夹具回答, + // 画布命令再叠在它上面。夹具与 `chat-composer.suite` 同一套,不另造聊天替身。 + const chatHarness = createProjectChatRuntimeHarness({ + projectPath: CANVAS_PROJECT_PATH, + initialSessionExists: false, + }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_project_revision') { @@ -140,10 +161,33 @@ function installTauri(): { layoutWrites: LayoutWrite[] } { if (command === 'list_local_project_asset_generations') { return []; } - return undefined; + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'inspect_local_project_directory') { + return { + projectPath: CANVAS_PROJECT_PATH, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'get_design_agent_runtime_mode') { + return null; + } + return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ + core: { invoke }, + event: { listen: chatHarness.listen }, + }); return { layoutWrites }; } @@ -185,21 +229,34 @@ function collectReferenceInserts() { }; } -async function mountCanvas() { - const tauri = installTauri(); +async function mountCanvas( + options: { + /** 项目附件(无 manifest 登记的素材来源)。 */ + attachments?: ProjectAttachmentResult[]; + /** 打开哪个栏目找卡片;未登记素材统一落在「待归类」。 */ + categoryLabel?: string; + } = {}, +) { + const { attachments = [], categoryLabel = '角色与对象' } = options; const manifest = manifestFor('chat-reference-drop', [ characterAsset('drop-a', 'a.png'), characterAsset('drop-b', 'b.png'), ]); + const tauri = installTauri(manifest); render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, - projectPath: '/tmp/chat-reference-drop', + projectPath: CANVAS_PROJECT_PATH, manifest, - attachments: [], + attachments, recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + // 对话面是真实链路:`App`(`directProjectMode`)→ `DirectProjectChatView` → + // `DirectProjectComposer`。桩接不住引用事件,链路断点正好在「谁把它落进草稿」, + // 所以这里不能再拿 `
项目总控
` 顶替。 + chat: React.createElement(AppComponent, { + initialProjectPath: CANVAS_PROJECT_PATH, + }), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -220,7 +277,7 @@ async function mountCanvas() { toJSON: () => ({}), } as DOMRect); act(() => window.dispatchEvent(new Event('resize'))); - fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' })); + fireEvent.click(screen.getByRole('button', { name: `打开${categoryLabel}` })); await settle(); const chat = document.querySelector('.game-workbench-chat')!; vi.spyOn(chat, 'getBoundingClientRect').mockReturnValue(CHAT_RECT); @@ -234,8 +291,22 @@ function cardIn(manager: HTMLElement, resourceId: string) { )!; } +/** + * 真实 DirectProject 输入盒草稿里的资源引用芯片,按 DOM 顺序读出 `resourceId`。 + * + * 事件派发本身不是交付结果:链路断点一直是「谁把它落进草稿」,所以断言必须读草稿, + * 不能再读 window 上的一次 dispatch。 + */ +function draftReferenceIds() { + return Array.from( + document.querySelectorAll( + 'form.project-chat-composer [data-resource-reference-id]', + ), + ).map((chip) => chip.getAttribute('data-resource-reference-id') ?? ''); +} + afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); vi.restoreAllMocks(); }); @@ -279,6 +350,8 @@ describe('拖动素材到对话:批量 @ 引用', () => { kind: 'character', source: 'resource-card', }); + // 端到端:这一批引用真的落进了真实 DirectProject 输入盒的草稿。 + await waitFor(() => expect(draftReferenceIds()).toEqual(['drop-a'])); // 拖到对话不是排版:一条坐标都不写。 expect(tauri.layoutWrites).toHaveLength(writesBefore); expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull(); @@ -316,8 +389,19 @@ describe('拖动素材到对话:批量 @ 引用', () => { expect(inserts).toHaveLength(1); expect( - new Set(inserts[0]!.map((reference) => reference.resourceId)), + new Set( + inserts[0]! + .filter( + (reference): reference is ResourceReference => + reference.type === 'resource', + ) + .map((reference) => reference.resourceId), + ), ).toEqual(new Set(['drop-a', 'drop-b'])); + // 端到端:整批一次插进同一份草稿,顺序就是拖动集合(画布可见顺序)的顺序。 + await waitFor(() => + expect(draftReferenceIds()).toEqual(['drop-a', 'drop-b']), + ); expect(tauri.layoutWrites).toHaveLength(writesBefore); dispose(); }); @@ -395,4 +479,81 @@ describe('拖动素材到对话:批量 @ 引用', () => { expect(inserts).toHaveLength(0); dispose(); }); + + it('未登记素材:不出现「引用」按钮,拖到对话栏只给原因', async () => { + // 导入的附件没有 manifest 登记(`manifestAssetId: null`),画布把它归到「待归类」。 + const { manager, inserts, dispose } = await mountCanvas({ + categoryLabel: '待归类', + attachments: [ + { + fileName: 'raw-shot.png', + mediaType: 'image/png', + status: 'imported', + localPath: 'assets/raw-shot.png', + }, + ], + }); + const card = cardIn(manager, 'attachment:assets/raw-shot.png'); + expect(card).not.toBeNull(); + + fireEvent.click(card); + // 引用入口与「编辑 / 删除」同一条 `manifestAssetId` 判据:没登记就没有这枚按钮。 + expect(screen.queryByRole('button', { name: /^引用资源/ })).toBeNull(); + + fireEvent.pointerDown(card, { + pointerId: 74, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(card, { + pointerId: 74, + buttons: 1, + clientX: 1000, + clientY: 300, + }); + // 落点提示也说清原因,不承诺一次插不进去的引用。 + expect( + screen.getByText('这些素材还没登记为项目资源,不能 @ 引用'), + ).not.toBeNull(); + + fireEvent.pointerUp(card, { + pointerId: 74, + button: 0, + clientX: 1000, + clientY: 300, + }); + await settle(); + + expect( + screen.getByText('选中的素材都还没登记为项目资源,暂时不能 @ 引用'), + ).not.toBeNull(); + expect(inserts).toHaveLength(0); + expect(draftReferenceIds()).toEqual([]); + dispose(); + }); + + it('空批次引用事件不报「没有可用的聊天输入区」', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { dispose } = await mountCanvas(); + // 派发器自己会挡掉空批次(`dispatchResourceReferenceInsertMany` 对空数组直接 return), + // 这里直接造事件:钉的是 App 侧不把「没有要插的东西」说成「没有输入区」。 + act(() => { + window.dispatchEvent( + new CustomEvent(RESOURCE_REFERENCE_INSERT_MANY_EVENT, { + detail: { references: [] }, + }), + ); + }); + await settle(); + + expect( + warn.mock.calls + .map((call) => String(call[0])) + .filter((message) => message.includes('没有可用的聊天输入区')), + ).toEqual([]); + expect(draftReferenceIds()).toEqual([]); + warn.mockRestore(); + dispose(); + }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts index b8102325e..f6eab39e7 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts @@ -82,7 +82,7 @@ describe('资源画布筛选模型', () => { 'unclassified', 'version', ]); - expect(RESOURCE_FILTER_REGION_OPTIONS[0].label).toBe('全部区域'); + expect(RESOURCE_FILTER_REGION_OPTIONS[0]!.label).toBe('全部区域'); }); it('区域显示名沿用 @ 面板那份唯一中文口径,并为项目版本单列', () => { @@ -257,23 +257,14 @@ describe('资源画布筛选模型', () => { }); it('筛选生效判据只看关键词与标签:区域只是画布当前栏目,不计入高亮', () => { - expect( - resourceFilterIsActive({ region: 'all', tags: [], keyword: '' }), - ).toBe(false); + expect(resourceFilterIsActive({ tags: [], keyword: '' })).toBe(false); // 纯空白关键词等同没有条件,否则按钮会为一次空输入长期高亮。 - expect( - resourceFilterIsActive({ region: 'all', tags: [], keyword: ' ' }), - ).toBe(false); + expect(resourceFilterIsActive({ tags: [], keyword: ' ' })).toBe(false); // 区域切到别的栏目不等于「有筛选」:用户滚轮换栏时按钮不该常亮。 - expect( - resourceFilterIsActive({ region: 'audio', tags: [], keyword: '' }), - ).toBe(false); - expect( - resourceFilterIsActive({ region: 'all', tags: ['主页'], keyword: '' }), - ).toBe(true); - expect( - resourceFilterIsActive({ region: 'all', tags: [], keyword: '按钮' }), - ).toBe(true); + // 现行签名只接收 tags/keyword,区域不在判据入参内。 + expect(resourceFilterIsActive({ tags: [], keyword: '' })).toBe(false); + expect(resourceFilterIsActive({ tags: ['主页'], keyword: '' })).toBe(true); + expect(resourceFilterIsActive({ tags: [], keyword: '按钮' })).toBe(true); }); it('标签库只统计传入区域内的资源,按使用次数降序、同次数按标签升序', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index ce3da42c4..64872ff66 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -38,7 +38,7 @@ const RESOURCE_FOCUS_SOURCE_LAYER: CanvasLayer = { originalWidth: 1024, originalHeight: 1024, zIndex: 1, - sourceType: 'upload', + sourceType: 'uploaded', }; describe('resourceCanvasFocusModel', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx index e6608e7f6..46d5c29c3 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx @@ -7,7 +7,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; -import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; +import { + ResourceCanvasGenerationPanelView, + type ResourceCanvasGenerationSubmitInput, +} from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; import { RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH, RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET, @@ -62,7 +65,10 @@ describe('生成浮层的面板外观与高度合同', () => { test('音频入口的浮层同样带 chrome 类,并复用宿主给的提交身份重试', async () => { const user = userEvent.setup(); - const onSubmit = vi.fn(async () => undefined); + // 显式泛型:否则 mock.calls 被推成空元组,下面取提交入参会报越界。 + const onSubmit = vi.fn<[input: ResourceCanvasGenerationSubmitInput], void>( + async () => undefined, + ); render( Supervisor
} + chat={null} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} @@ -357,7 +358,7 @@ function HostWorkbenchWithReferenceRemoval({ attachments={[]} recentRunStatus={null} recentRunStopReason={null} - supervisor={
Supervisor
} + chat={null} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx index 5c5b7bcb8..2ce2aea2a 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx @@ -29,10 +29,11 @@ import { within, } from './appSurface/harness'; import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); vi.restoreAllMocks(); }); @@ -230,7 +231,7 @@ function installTauri({ return undefined; }, ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); return { invoke, layoutWrites, generationStarts, deriveInputs, unexpected }; } @@ -281,7 +282,8 @@ function Workbench({ attachments={[]} recentRunStatus={null} recentRunStopReason={null} - supervisor={
Supervisor
} + // ProjectDevelopmentViewProps 现在必填 chat;本用例只验资源落点,不需要对话区。 + chat={null} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} // 收尾时宿主会重读权威清单:把它接进状态,新素材才会进资源投影(落点的前提)。 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx index b6c627d0b..c7f2f107e 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx @@ -15,6 +15,7 @@ import { within, } from './appSurface/harness'; import { repoPath } from './repoPath'; +import { installTauriRuntime } from './tauriRuntimeFake'; /** * 「生成任务」侧栏与右上角入口的三条口径: @@ -61,7 +62,8 @@ function installInvoke() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + // 该 mock 按命令返回固定结构,无法满足 TauriInvoke 的泛型逐命令建模,故断言为 window.__TAURI__。 + installTauriRuntime({ core: { invoke } }); } async function renderWorkbench(projectId: string) { @@ -188,7 +190,7 @@ describe('「生成任务」侧栏的开合与入口位置', () => { attachments: [], recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts index e49d9ee46..1310098b7 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts @@ -4,6 +4,7 @@ import { GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, isSafeProjectResourceCanvasCoordinate, + type ProjectResourceCanvasPosition, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createEmptyResourceCanvasLayout, diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx index d9edbb541..d90f37c1b 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx @@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { + GameCreationAppAssetKind, GameCreationAppAssetManifestEntry, GameCreationAppManifest, ProjectResourceCanvasCategory, @@ -25,6 +26,7 @@ import { screen, within, } from './appSurface/harness'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; /** * 「AGC 资源画布:新素材不自动重排 + 生成后自动聚焦 + 显式整理画布」的行为级验收。 @@ -41,6 +43,15 @@ const NEW_RESOURCE_ID = `asset:${NEW_ASSET_ID}`; type AssetFixture = GameCreationAppAssetManifestEntry; +/** + * 历史夹具沿用旧 kind `game-rules`,它已不在当前 `GameCreationAppAssetKind` 联合里; + * 用例依赖「未识别 kind」的运行时语义,因此保留原字符串,只在类型层收口。 + */ +function legacyAssetKind(kind: string): GameCreationAppAssetKind { + // 旧 kind 字面量与当前联合结构上不可兼容,这里显式转换以保留原始串的运行时语义。 + return kind as unknown as GameCreationAppAssetKind; +} + function pngAsset(id: string, fileName: string): AssetFixture { return { id, @@ -55,7 +66,7 @@ function pngAsset(id: string, fileName: string): AssetFixture { function markdownAsset(id: string, fileName: string): AssetFixture { return { id, - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: `docs/${fileName}`, source: { kind: 'generated', resourceId: `${id}-resource` }, @@ -217,7 +228,7 @@ function installLayoutTauri( }, ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); return { invoke, layoutWrites, layoutReads, unexpectedCommands }; } @@ -336,7 +347,7 @@ function LayoutWorkbench({ } afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); vi.restoreAllMocks(); }); @@ -719,9 +730,9 @@ describe('资源画布手动重排口径', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, - } as unknown as typeof window.__TAURI__; + }); const resources: ResourceCanvasItem[] = ['resource-a', 'resource-b'].map( (id) => ({ @@ -1148,7 +1159,9 @@ describe('资源画布手动重排口径', () => { // 位置:整理画布仍在排序组左侧,不会被读成排列方式的一部分。 const rowButtons = Array.from(actionsRow!.querySelectorAll('button')); expect(rowButtons.at(-1)).not.toBe(rederiveButton); - const rederiveIndex = rowButtons.indexOf(rederiveButton); + const rederiveIndex = rowButtons.findIndex( + (button) => button === rederiveButton, + ); const sortGroupIndex = rowButtons.findIndex((button) => sortGroup.contains(button), ); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx index c98db582b..bc1605d71 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx @@ -22,7 +22,7 @@ vi.mock('@tauri-apps/api/core', async () => ({ '@tauri-apps/api/core', )), invoke: (command: string, args?: Record) => - window.__TAURI__!.core.invoke(command, args), + window.__TAURI__!.core!.invoke!(command, args), })); const PROJECT_PATH = '/tmp/quick-edit-draft'; @@ -43,21 +43,21 @@ function createDraftManifest(): GameCreationAppManifest { manifest.assets = [ { id: 'source-art', - kind: 'art-image', + kind: 'image', mediaType: 'image/png', localPath: 'assets/source-art.png', source: { kind: 'generated', resourceId: 'art-resource' }, }, { id: 'source-art-2', - kind: 'art-image', + kind: 'image', mediaType: 'image/png', localPath: 'assets/source-art-2.png', source: { kind: 'generated', resourceId: 'art-resource-2' }, }, { id: 'source-rules', - kind: 'game-rules', + kind: 'document', mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, @@ -81,7 +81,7 @@ function createCanvasTaskArtManifest(): GameCreationAppManifest { manifest.assets = [ { id: 'source-rules', - kind: 'game-rules', + kind: 'document', mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, @@ -214,7 +214,7 @@ function installInvoke( normalizeCalls.push(input); const asset = { id: 'normalized-task-art', - kind: 'art-image', + kind: 'image' as const, mediaType: 'image/png', localPath: CANVAS_TASK_ART_PATH, source: { @@ -246,7 +246,7 @@ function installInvoke( const assetId = `edit-${operationId}`; const asset = { id: assetId, - kind: 'art-image', + kind: 'image' as const, mediaType: 'image/png', localPath: `assets/edits/${operationId}-art.png`, source: { @@ -290,7 +290,7 @@ function DraftWorkbench({ * 触发的正规化无关——这正是「别的流程换了卡片 id」的那条旁路。 */ externalAssetPath?: string | null; -} = {}) { +}) { const [manifest, setManifest] = React.useState(createManifest); return React.createElement( React.Fragment, @@ -307,7 +307,7 @@ function DraftWorkbench({ ...current.assets, { id: 'external-art', - kind: 'art-image', + kind: 'image', mediaType: 'image/png', localPath: externalAssetPath, source: { @@ -328,7 +328,7 @@ function DraftWorkbench({ attachments: [], recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), onManifestChange: ( diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts index 4f9c6caf8..41f088ce0 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts @@ -26,13 +26,15 @@ const SOURCE_LAYER: CanvasLayer = { originalWidth: 1024, originalHeight: 1024, zIndex: 1, - sourceType: 'upload', + // 现行 CanvasSourceType 只认 'uploaded' / 'generated' / 'mock_generated'。 + sourceType: 'uploaded', }; const RULES_REFERENCE: ChatReference = { type: 'resource', resourceId: 'rules-resource', - kind: 'game-rules', + // 旧的 `game-rules` kind 已不在当前联合里,夹具按真实类型收敛到最接近的 document。 + kind: 'document', mediaType: 'text/markdown', label: 'rules', category: 'document', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts index 14d349f43..532e238d9 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts @@ -198,7 +198,7 @@ describe('resource canvas toolbar actions', () => { completedManifest, createResource({ manifestAssetId: null, - producerTaskId: completedManifest.tasks[0].id, + producerTaskId: completedManifest.tasks[0]!.id, }), ), ).toEqual(['quick-edit', 'download']); @@ -212,7 +212,7 @@ describe('resource canvas toolbar actions', () => { runningManifest, createResource({ manifestAssetId: null, - producerTaskId: runningManifest.tasks[0].id, + producerTaskId: runningManifest.tasks[0]!.id, }), ), ).toEqual(['download']); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts index eff1f4445..8468fe223 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts @@ -86,10 +86,10 @@ describe('运行模块版本绑定判定', () => { }); test('labels a version with its Chinese created reason and creation time', () => { - expect(formatIterationVersionLabel(versions[0])).toBe( + expect(formatIterationVersionLabel(versions[0]!)).toBe( `初始版本 · ${new Date(1_788_075_047 * 1000).toLocaleString('zh-CN')}`, ); - expect(formatIterationVersionLabel(versions[1])).toBe( + expect(formatIterationVersionLabel(versions[1]!)).toBe( `智能体修订 · ${new Date(1_788_075_104 * 1000).toLocaleString('zh-CN')}`, ); }); @@ -100,7 +100,7 @@ describe('运行模块版本绑定判定', () => { const createdAtLocal = new Date(1_788_075_047 * 1000).toLocaleString( 'zh-CN', ); - const label = formatIterationVersionLabel(versions[0]); + const label = formatIterationVersionLabel(versions[0]!); expect(label).toBe(`初始版本 · ${createdAtLocal}`); // 年 / 月 / 日必须来自真实时刻,而不是 1970。这里用显式数值选项断言, diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx index f1a0ccc52..4a7ef6eb6 100644 --- a/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx @@ -42,6 +42,7 @@ import { parseStyleSheet, resolveDeclarations, } from './styleCascade'; +import { installTauriRuntime } from './tauriRuntimeFake'; const PROJECT_PATH = '/tmp/workbench-preview-alpha'; const PROJECT_ID = 'workbench-preview-alpha'; @@ -153,7 +154,7 @@ function createFixtureManifest(): GameCreationAppManifest { mediaType: string, ) => ({ id, - kind: 'character', + kind: 'character' as const, category: 'character' as const, mediaType, localPath, @@ -248,7 +249,7 @@ function installPreviewTauri(manifest: GameCreationAppManifest) { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); return invoke; } diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts index abf5030af..c7285b08d 100644 --- a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts @@ -11,6 +11,7 @@ import { describe, expect, test } from 'vitest'; import { gameCreationAppAssetCategory, + type GameCreationAppAssetKind, type GameCreationAppAssetManifestEntry, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { projectResourceCardPreviewKind } from '../src/view/project-development/resourceCardPreviewModel'; @@ -74,6 +75,9 @@ const REAL_ASSET_SHAPES: readonly Shape[] = [ }, ]; +/** 真机磁盘上遗留的 raw kind:不在 canonical 词表内,故意保留原始串。 */ +const REAL_FUTURE_UI_KIND: string = 'future-ui'; + function buildRealAssets(): GameCreationAppAssetManifestEntry[] { const assets: GameCreationAppAssetManifestEntry[] = []; let index = 0; @@ -82,7 +86,9 @@ function buildRealAssets(): GameCreationAppAssetManifestEntry[] { index += 1; assets.push({ id: `asset-${index}`, - kind: shape.kind, + // 夹具要复现真机磁盘上的非法 kind 串(解析后收口成 unknown), + // 故这里从 string 收窄到 canonical 词表类型,只走类型层。 + kind: shape.kind as GameCreationAppAssetKind, mediaType: shape.mediaType, localPath: `assets/probe-${index}.${shape.ext}`, source: { kind: 'generated' }, @@ -120,7 +126,9 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { test('存量 57 条无法解析的 kind 的落盘 unclassified 停在「待归类」', () => { const assets = realManifest().assets; - const uiAssets = assets.filter((asset) => asset.kind === 'future-ui'); + const uiAssets = assets.filter( + (asset) => asset.kind === REAL_FUTURE_UI_KIND, + ); // 前置事实:这 57 条落盘值确实是 unclassified(历史误判现场)。 expect(uiAssets).toHaveLength(57); @@ -156,9 +164,14 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { }), ).toBe('ui-interaction'); // 无法解析的输入统一收口为 unknown → unclassified。 - for (const raw of ['UI', 'future-kind', 'future-ui']) { + // raw 显式声明为 string:夹具要喂 canonical 词表之外的串,从 string 收窄只走类型层。 + const rawKinds: string[] = ['UI', 'future-kind', 'future-ui']; + for (const raw of rawKinds) { expect( - gameCreationAppAssetCategory({ kind: raw, category: 'unclassified' }), + gameCreationAppAssetCategory({ + kind: raw as GameCreationAppAssetKind, + category: 'unclassified', + }), ).toBe('unclassified'); } }); @@ -166,7 +179,12 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { test('落盘 unclassified 且 kind 派生也是 unclassified 时不受影响', () => { // image / video / code 的 canonical 分类本来就是 unclassified, // 这类资产必须继续信任落盘值,不被重派生规则误伤。 - for (const kind of ['image', 'video', 'code', 'publication-material']) { + for (const kind of [ + 'image', + 'video', + 'code', + 'publication-material', + ] as const) { expect( gameCreationAppAssetCategory({ kind, category: 'unclassified' }), ).toBe('unclassified'); diff --git a/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx index 81f031887..8e2596d03 100644 --- a/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx @@ -3,6 +3,8 @@ import { render, screen, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import type { GameCreationAppAssetKind } from '../../../packages/shared/src/contracts/gameCreationApp'; + // 浮层外壳(portal + 焦点陷阱)不在本用例关注面内:与文档预览浮层的用例同口径只渲染子节点。 vi.mock('../src/components/modal/ThemedModal', () => ({ ThemedModal: ({ @@ -40,7 +42,7 @@ import { type EngineCase = { path: string; mediaType: string; - kind: string; + kind: GameCreationAppAssetKind; projectedKind: 'art' | 'audio' | 'code' | 'document'; previewKind: | 'model' @@ -234,15 +236,43 @@ const ENGINE_CASES: EngineCase[] = [ }, ]; +/** + * 预览分支只读 `path` / `mediaType` / `subtype`,但预览模型的入参是完整的 + * `ProjectResource`;这里补齐投影产物的其余必填字段,构造与被测链路同形状的对象。 + */ +function resourceFixture(input: { + id: string; + path: string; + mediaType: string; + subtype: string; +}): ProjectResource { + return { + id: input.id, + category: 'unclassified', + subtype: input.subtype, + label: input.path.split('/').pop() ?? input.path, + path: input.path, + mediaType: input.mediaType, + sourceLabel: '', + taskTitle: null, + manifestAssetId: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + }; +} + describe('Cocos 资源在资源画布上的准入与预览契约', () => { it('每个引擎资源都能进画布、落到预期卡面分支与类型角标', () => { for (const entry of ENGINE_CASES) { - const resource = { + const resource = resourceFixture({ id: entry.path, path: entry.path, mediaType: entry.mediaType, subtype: entry.kind, - }; + }); expect( projectedResourceKind({ path: entry.path, @@ -266,20 +296,24 @@ describe('Cocos 资源在资源画布上的准入与预览契约', () => { it('模型走 model 读取分支、图片容器走 art,二进制容器不读字节', () => { expect( - projectResourceMediaPreviewCategory({ - id: 'glb', - path: 'assets/model/hero.glb', - mediaType: 'model/gltf-binary', - subtype: 'scene', - }), + projectResourceMediaPreviewCategory( + resourceFixture({ + id: 'glb', + path: 'assets/model/hero.glb', + mediaType: 'model/gltf-binary', + subtype: 'scene', + }), + ), ).toBe('model'); expect( - projectResourceMediaPreviewCategory({ - id: 'tga', - path: 'assets/tex/grass.tga', - mediaType: 'image/x-tga', - subtype: 'image', - }), + projectResourceMediaPreviewCategory( + resourceFixture({ + id: 'tga', + path: 'assets/tex/grass.tga', + mediaType: 'image/x-tga', + subtype: 'image', + }), + ), ).toBe('art'); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx index 66107fa25..a8a214249 100644 --- a/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx @@ -4,16 +4,17 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { createRef } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import type { ResourceFilterRegion } from '../src/view/project-development/resourceCanvasFilterModel'; import { ResourceFilterPanel } from '../src/view/project-development/ResourceFilterPanel'; function renderPanel( overrides: { keyword?: string; - region?: 'all' | 'ui-interaction' | 'character'; + region?: ResourceFilterRegion; activeTags?: string[]; tagItems?: Array<{ tag: string; assetCount: number }>; onKeywordChange?: (value: string) => void; - onRegionChange?: (region: string) => void; + onRegionChange?: (region: ResourceFilterRegion) => void; onToggleTag?: (tag: string) => void; onClose?: () => void; } = {}, @@ -35,7 +36,7 @@ function renderPanel( keyword={overrides.keyword ?? ''} onKeywordChange={handlers.onKeywordChange} region={overrides.region ?? 'all'} - onRegionChange={handlers.onRegionChange as (region: 'all') => void} + onRegionChange={handlers.onRegionChange} activeTags={overrides.activeTags ?? []} onToggleTag={handlers.onToggleTag} tagItems={ diff --git a/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx index 36230170a..fc6f70325 100644 --- a/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx @@ -1,7 +1,10 @@ /** @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { GameCreationAppAssetKind } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetKind, + GameCreationAppAssetManifestEntry, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import { createGameCreationAppManifest, fireEvent, @@ -13,13 +16,14 @@ import { waitFor, within, } from './appSurface/harness'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; vi.mock('@tauri-apps/api/core', async () => ({ ...(await vi.importActual( '@tauri-apps/api/core', )), invoke: (command: string, args?: Record) => - window.__TAURI__!.core.invoke(command, args), + window.__TAURI__!.core!.invoke!(command, args), })); const projectId = 'json-canvas'; @@ -37,11 +41,14 @@ async function mountJsonCanvas( ) { installResizeObserverStub(); const manifest = createGameCreationAppManifest(projectId, 'JSON 资源测试'); - manifest.assets = [ + const baseAssets: Array< + Pick + > = [ { id: 'design', kind, localPath: 'ui/design.json' }, { id: 'ordinary', kind: 'unknown', localPath: 'assets/ordinary.json' }, { id: 'spoof', kind: 'unknown', localPath: 'ui/spoof.json' }, - ].map((asset) => ({ + ]; + manifest.assets = baseAssets.map((asset) => ({ ...asset, mediaType: 'application/json', category: 'document' as const, @@ -118,7 +125,7 @@ async function mountJsonCanvas( throw new Error(`unexpected command ${command}`); }, ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); const onManifestChange = vi.fn(); render( 对话
} + onHomeOpen={() => {}} + onProjectsOpen={() => {}} onManifestChange={onManifestChange} />, ); @@ -146,7 +155,7 @@ async function mountJsonCanvas( } afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); }); describe('JSON 画布卡片与入口', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index d07631abb..96115110d 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -22,6 +22,7 @@ import { import { afterEach, describe, expect, test, vi } from 'vitest'; import type { + GameCreationAppAssetKind, GameCreationAppAssetManifestEntry, GameIterationVersion, } from '../../../packages/shared/src/contracts/gameCreationApp'; @@ -67,6 +68,7 @@ import { resourceReferenceTagLibrary, type RuntimeRegionReference, } from '../src/features/project-workspace/resourceReferences'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; /** * jsdom 没有 `DragEvent` / `ClipboardEvent` 构造器,而 Lexical 的默认粘贴路径按构造器名字判断 @@ -129,7 +131,7 @@ defineResizeObserverShim(); function asset( id: string, - kind: string, + kind: GameCreationAppAssetKind, mediaType: string, localPath: string, ): GameCreationAppAssetManifestEntry { @@ -415,7 +417,7 @@ describe('ResourceReferenceInput', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); try { render( @@ -438,7 +440,7 @@ describe('ResourceReferenceInput', () => { expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog'); }); } finally { - delete window.__TAURI__; + resetTauriRuntime(); } }); @@ -606,7 +608,7 @@ describe('ResourceReferenceInput', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); try { const ref = createRef(); @@ -652,7 +654,7 @@ describe('ResourceReferenceInput', () => { { type: 'input_text', text: ' 出图' }, ]); } finally { - delete window.__TAURI__; + resetTauriRuntime(); } }); @@ -694,7 +696,7 @@ describe('ResourceReferenceInput', () => { }); test('AI 润色只换文本时不会把引用一轮轮追加成重复 @显示名', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); function Controlled() { const composerRef = createRef(); @@ -739,7 +741,7 @@ describe('ResourceReferenceInput', () => { }); test('引用后面的段落分隔原样落进 content:读回的 prompt 与编辑器分段逐字一致', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); function Controlled() { const composerRef = createRef(); @@ -785,7 +787,7 @@ describe('ResourceReferenceInput', () => { }); test('润色改写掉 `@附件名` token 时,附件 part 补在末尾而不是丢掉', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); const attachment = { type: 'agc_attachment_reference' as const, @@ -828,7 +830,7 @@ describe('ResourceReferenceInput', () => { }); test('润色回包还留着 `@名称` token 时,引用与附件都在原位换回真 part', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); const attachment = { type: 'agc_attachment_reference' as const, @@ -878,7 +880,7 @@ describe('ResourceReferenceInput', () => { }); test('润色回写时 provider 答不出 token 的 part 仍留在草稿里,不在这门翻译里消失', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); // provider 契约被破坏:解析得出引用,但答不出 token。它仍然必须活过这一轮润色回写。 const tokenlessProvider: ReferenceProvider = { @@ -982,7 +984,7 @@ describe('ResourceReferenceInput', () => { test('opens the asset picker, supports multi-select, and inserts stable references', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); render( { }); test('一次 insertReferences 按给定顺序插入整批 chip', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const composerRef = createRef(); render( { test('removing a chip keeps the remaining text editable', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); render( { test('keeps the whole reference after one delete and drops its data in one step', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); render( { test('refreshes chip and candidate display names after a resource rename', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const composerRef = createRef(); const renamedAssets = [ asset('hero', 'character', 'image/png', 'assets/hero-final.png'), @@ -1513,7 +1515,7 @@ describe('ResourceReferenceInput', () => { test('restores a cross-session draft with the caret at the end of the text', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const composerRef = createRef(); render( { }); test('聊天与快速编辑提示词插入同一素材:草稿文本与引用完全一致', async () => { - const chatOnChange = vi.fn<(draft: ChatComposerDraft) => void>(); - const quickEditOnChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const chatOnChange = vi.fn<[draft: ChatComposerDraft], void>(); + const quickEditOnChange = vi.fn<[draft: ChatComposerDraft], void>(); render( <> ({ + versions: manifest.versions!.map((version) => ({ ...version, resourceBindings: [ { slotId: 'asset:asset-final', resourceId: 'asset-final' }, @@ -356,7 +357,7 @@ function renderReplacementWorkbench(options: RenderOptions = {}) { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const onActiveVersionChange = vi.fn(); const onPlay = vi.fn(); diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts index 2352a8a09..fca11932e 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts @@ -1,7 +1,10 @@ /** @vitest-environment jsdom */ import { describe, expect, it, vi } from 'vitest'; -import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetKind, + GameCreationAppManifest, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import type { LocalProjectVersionReplacementCandidate, ProjectVersionResourceCompatibility, @@ -21,6 +24,7 @@ import { replaceVersionResource, } from '../src/features/resource-canvas/resourceVersionReplacementTransport'; import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel'; +import { installTauriRuntime } from './tauriRuntimeFake'; function candidate( resourceId: string, @@ -47,7 +51,7 @@ function candidate( function manifestWithAssets( assets: Array<{ id: string; - kind: string; + kind: GameCreationAppAssetKind; mediaType: string; localPath: string; }>, @@ -357,7 +361,7 @@ describe('候选择序与资源映射', () => { describe('资源替换的 IPC 边界', () => { it('两个命令都只传一个 input 对象,字段名与 Rust 结构体逐字一致', async () => { const invoke = vi.fn(async () => ({ ok: true })); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); await readVersionResourceReplacementCandidates({ projectPath: '/tmp/project', diff --git a/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx index 758668eaa..ba5b4bf63 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx @@ -109,6 +109,7 @@ function renderWorkbench( props: { activeVersionId?: string | null; onActiveVersionChange?: (versionId: string) => void; + onPlay?: () => void; } = {}, ) { const { rerender } = render( @@ -321,7 +322,7 @@ describe('C7 运行模块版本切换', () => { try { render( React.createElement(GameRunVersionPicker, { - versions: createVersionedManifest().versions, + versions: createVersionedManifest().versions ?? [], activeVersionId: null, onSelectVersion: vi.fn(), }), @@ -358,7 +359,7 @@ describe('C7 运行模块版本切换', () => { const onSelectVersion = vi.fn(); render( React.createElement(GameRunVersionPicker, { - versions: createVersionedManifest().versions, + versions: createVersionedManifest().versions ?? [], activeVersionId: null, onSelectVersion, }), @@ -389,7 +390,7 @@ describe('C7 运行模块版本切换', () => { test('keeps the portal menu mounted when the click lands on the menu itself', async () => { render( React.createElement(GameRunVersionPicker, { - versions: createVersionedManifest().versions, + versions: createVersionedManifest().versions ?? [], activeVersionId: null, onSelectVersion: vi.fn(), }), diff --git a/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx b/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx index 029445850..15d53e54e 100644 --- a/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx +++ b/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx @@ -4,8 +4,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import ProjectDevelopmentView from '../src/view/project-development'; +import { installTauriRuntime } from './tauriRuntimeFake'; -const openUrl = vi.fn(async () => undefined); +const openUrl = vi.fn(async (_url: string) => undefined); vi.mock('@tauri-apps/plugin-opener', () => ({ openUrl: (url: string) => openUrl(url), })); @@ -13,7 +14,7 @@ vi.mock('@tauri-apps/plugin-opener', () => ({ const PREVIEW_URL = 'http://127.0.0.1:4173/'; function installInvoke() { - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: vi.fn(async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { @@ -38,7 +39,7 @@ function installInvoke() { throw new Error(`unexpected invoke ${command}`); }), }, - } as unknown as typeof window.__TAURI__; + }); } function renderRunView(options: { withPreview?: boolean } = {}) { @@ -61,7 +62,7 @@ function renderRunView(options: { withPreview?: boolean } = {}) { ? null : { status: 'running', url: PREVIEW_URL, port: 4173 } } - supervisor={
项目总控
} + chat={
项目总控
} onHomeOpen={vi.fn()} onProjectsOpen={vi.fn()} onNotice={onNotice} diff --git a/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx b/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx index 2b289d564..199efc3e6 100644 --- a/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx +++ b/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx @@ -4,11 +4,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import ProjectDevelopmentView from '../src/view/project-development'; +import { installTauriRuntime } from './tauriRuntimeFake'; const PREVIEW_URL = 'http://127.0.0.1:4173/'; function installInvoke() { - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: vi.fn(async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { @@ -33,7 +34,7 @@ function installInvoke() { throw new Error(`unexpected invoke ${command}`); }), }, - } as unknown as typeof window.__TAURI__; + }); } function renderRunView() { @@ -51,7 +52,8 @@ function renderRunView() { recentRunStatus={null} recentRunStopReason={null} preview={{ status: 'running', url: PREVIEW_URL, port: 4173 }} - supervisor={
项目总控
} + // ProjectDevelopmentViewProps 现在必填 chat;旧的 supervisor 入参已不存在。 + chat={null} onHomeOpen={vi.fn()} onProjectsOpen={vi.fn()} onNotice={vi.fn()} diff --git a/apps/ai-game-creator-shell/tests/separationOverview.test.ts b/apps/ai-game-creator-shell/tests/separationOverview.test.ts index 4761832ad..ead2bf2cc 100644 --- a/apps/ai-game-creator-shell/tests/separationOverview.test.ts +++ b/apps/ai-game-creator-shell/tests/separationOverview.test.ts @@ -72,7 +72,9 @@ function node( source: 'System', }, component, + children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -157,7 +159,7 @@ describe('separation status writeback', () => { ]), ).toEqual([]); expect( - problematicTree[0].root.children[0]?.metadata.component_status, + problematicTree[0]?.root.children[0]?.metadata.component_status, ).toEqual({ NeedReview: '自动切分重试已达上限(3 次)\n第一次建议\n最后建议', }); diff --git a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts index 558a8a6a4..2443b834c 100644 --- a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts +++ b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts @@ -3,22 +3,41 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it, vi } from 'vitest'; import type { GameCreationAppPreviewState } from '../../../packages/shared/src/contracts/gameCreationApp'; -import { resolveSessionPreviewOnProjectOpen } from '../src/features/app-shell/sessionPreview'; +import { + type PreviewCommandInvoke, + resolveSessionPreviewOnProjectOpen, +} from '../src/features/app-shell/sessionPreview'; import { repoPath } from './repoPath'; const projectPath = '/tmp/session-preview-project'; +type RawPreviewInvoke = ( + command: string, + args?: Record, +) => Promise; + +// 泛型 invoke 与具体的非泛型 mock 结构上不可兼容,只能用 as unknown as 收敛; +// 运行时原样返回同一个 mock,调用方仍持有原引用。 +function asPreviewCommandInvoke( + invoke: RawPreviewInvoke, +): PreviewCommandInvoke { + return invoke as unknown as PreviewCommandInvoke; +} + function createInvoke( responses: Record, calls: Array<[string, Record | undefined]> = [], ) { - return vi.fn(async (command: string, args?: Record) => { - calls.push([command, args]); - if (!(command in responses)) { - throw new Error(`unexpected invoke ${command}`); - } - return responses[command]; - }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + calls.push([command, args]); + if (!(command in responses)) { + throw new Error(`unexpected invoke ${command}`); + } + return responses[command]; + }, + ); + return asPreviewCommandInvoke(invoke); } describe('进入项目时的预览活体核验', () => { @@ -94,14 +113,14 @@ describe('进入项目时的预览活体核验', () => { it('registry 不可读时不顺手停预览:读失败不等于确认没有在跑', async () => { const calls: Array<[string, Record | undefined]> = []; - const invoke = vi.fn( - async (command: string, args?: Record) => { + const invoke = asPreviewCommandInvoke( + vi.fn(async (command: string, args?: Record) => { calls.push([command, args]); if (command === 'get_local_game_preview_status') { throw new Error('需要在陶泥儿客户端内运行'); } return { status: 'stopped', url: null, port: null, root: null }; - }, + }), ); const resolution = await resolveSessionPreviewOnProjectOpen({ diff --git a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts index b7677ead3..970f7e95f 100644 --- a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts +++ b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts @@ -40,6 +40,7 @@ function node(id: string, status: StageStatus, children: Node[] = []): Node { component: null, children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index efbd54f1d..20c943336 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -7,23 +7,23 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, test, vi } from 'vitest'; import { - ensureAdminWeb, - ensureBackend, + ensureAdminWeb as ensureAdminWebImpl, + ensureBackend as ensureBackendImpl, formatOwnerLabel, formatStartupSummary, - isBackendReady, + isBackendReady as isBackendReadyImpl, isProcessGroupAlive, isWorktreeApiServerOwner, isWorktreeSpacetimeOwner, - preflightExistingVite, + preflightExistingVite as preflightExistingViteImpl, readAdminWebEnabled, readBackendServiceFailure, - readLinuxProcessGroupAlive, - readWindowsPortOwnerIdentities, + readLinuxProcessGroupAlive as readLinuxProcessGroupAliveImpl, + readWindowsPortOwnerIdentities as readWindowsPortOwnerIdentitiesImpl, resolveBackendTargetsFromState, - runWindowsTaskkill, + runWindowsTaskkill as runWindowsTaskkillImpl, spawnChild, - startAdminWeb, + startAdminWeb as startAdminWebImpl, stopChild, terminateChildTree, verifyAgcBackendOwnership, @@ -31,6 +31,209 @@ import { waitForChildTermination, } from '../scripts/start-dev-stack.mjs'; +// scripts/*.mjs 没有 JSDoc:TS 只能按默认值推导签名,可选回调参数被推成具体实现、 +// 注入点被推成 null/never[],与测试使用的 mock 结构不兼容。下面按运行时真实契约重新 +// 声明局部签名并重绑定导入实现,只做类型层校正,调用方式与运行时行为完全不变。 + +type EndpointPortRange = { start: number; end: number; label: string }; + +type PortOwnerIdentity = { + port?: number; + processId?: number; + name?: string | null; + executablePath?: string | null; + commandLine?: string | null; +}; + +type BackendServiceFixture = { + status: string; + url: string; + repoRoot?: string; + instanceId?: string; + exitCode?: number; + signal?: string | null; +}; + +type BackendStateFixture = { + schemaVersion: number; + repoRoot?: string; + instanceId?: string; + database: string; + updatedAt: string; + spacetimeDataDir?: string; + services: Record; +}; + +type BackendOwnershipResult = { + ok: boolean; + reason: string; + owners?: Map; + apiOwner?: PortOwnerIdentity; + workerOwner?: PortOwnerIdentity; + spacetimeOwner?: PortOwnerIdentity; +}; + +type IsBackendReadyOptions = { + state?: BackendStateFixture; + isReady?: (url: string) => boolean | Promise; + verifyOwnership?: (options?: { + apiUrl?: string; + spacetimeUrl?: string; + bgfilterWorkerUrl?: string; + }) => BackendOwnershipResult | Promise; + onOwnershipRejected?: ((ownership: BackendOwnershipResult) => void) | null; +}; + +// 导入实现本身是同一份,仅按运行时契约修正推导过窄的签名(JS 默认值导致结构上不可兼容)。 +const isBackendReady = isBackendReadyImpl as unknown as ( + options?: IsBackendReadyOptions, +) => Promise; + +type SpawnSyncLike = ( + command: string, + args: readonly string[], + options?: { env?: Record }, +) => { status: number | null; error?: unknown; stdout: string }; + +type ReadWindowsPortOwnersOptions = { + spawnImpl?: SpawnSyncLike; + env?: Record; + now?: () => number; + commandLineTtlMs?: number; + commandLineCache?: Map; +}; + +// 同上:spawnImpl 在 JS 推断里被收窄为 Node 的 spawnSync 重载,测试注入的同步 mock 不可直接赋值。 +const readWindowsPortOwnerIdentities = + readWindowsPortOwnerIdentitiesImpl as unknown as ( + ports: number[], + options?: ReadWindowsPortOwnersOptions, + ) => Map | null; + +type ReadLinuxProcessGroupAliveOptions = { + readdirImpl?: (path: string) => string[]; + readFileImpl?: (path: string, encoding: string) => string; +}; + +// 同上:readdirImpl/readFileImpl 在 JS 推断里被收窄为 readdirSync/readFileSync 的重载。 +const readLinuxProcessGroupAlive = + readLinuxProcessGroupAliveImpl as unknown as ( + processGroupId: number, + options?: ReadLinuxProcessGroupAliveOptions, + ) => boolean | null; + +type ChildLike = EventEmitter & { + pid?: number; + exitCode?: number | null; + signalCode?: string | null; + kill?: (signal?: string | number) => unknown; +}; + +type EnsureBackendOptions = { + onBackendChild?: (child: ChildLike) => void; + checkBackendReady?: () => boolean | Promise; + resolveTargets?: () => unknown; + spawnBackend?: () => ChildLike; + waitUntilReady?: (child: ChildLike) => unknown | Promise; +}; + +// 同上:spawnBackend 在 JS 推断里被收窄为返回完整 ChildProcessWithoutNullStreams。 +const ensureBackend = ensureBackendImpl as unknown as ( + options?: EnsureBackendOptions, +) => Promise<{ backendChild: ChildLike | null; targets: unknown }>; + +type TaskkillChildLike = EventEmitter & { + kill: (signal?: string | number) => unknown; +}; + +type RunWindowsTaskkillOptions = { + spawnImpl?: ( + command: string, + args: readonly string[], + options: { shell: boolean; stdio: string; windowsHide: boolean }, + ) => TaskkillChildLike; + timeoutMs?: number; +}; + +// 同上:spawnImpl 在 JS 推断里被收窄为 Node 的 spawn 重载,测试注入的 EventEmitter mock 不可直接赋值。 +const runWindowsTaskkill = runWindowsTaskkillImpl as unknown as ( + processId: number, + options?: RunWindowsTaskkillOptions, +) => Promise<{ timedOut: boolean; code: number | null; error: unknown }>; + +type PreflightEndpoint = { + host: string; + port: number; + url: string; + markerUrl: string; + portRange: EndpointPortRange | null; +}; + +type PreflightExistingViteOptions = { + endpoint?: PreflightEndpoint; + readServer?: ( + endpoint: PreflightEndpoint, + ) => Promise<{ statusCode: number; body: string } | null>; + portListening?: (endpoint: PreflightEndpoint) => Promise; + readMarker?: (endpoint: PreflightEndpoint) => Promise; +}; + +// 同上:endpoint 在 JS 推断里被收窄为 portRange: null(createAgcDevEndpoint 的默认值)。 +const preflightExistingVite = preflightExistingViteImpl as unknown as ( + options?: PreflightExistingViteOptions, +) => Promise<{ status: string; apiTarget: string }>; + +type AdminWebEndpoint = { + host: string; + port: number; + basePath?: string; + url: string; +}; + +type AdminWebChild = { pid?: number }; + +type StartAdminWebOptions = { + env?: Record; + spawnImpl?: ( + command: string, + args: string[], + options: { cwd: string; env: Record }, + ) => AdminWebChild; +}; + +// 同上:spawnImpl 在 JS 推断里被收窄为 spawnChild 的完整子进程返回类型。 +const startAdminWeb = startAdminWebImpl as unknown as ( + apiUrl: string, + endpoint: AdminWebEndpoint, + options?: StartAdminWebOptions, +) => AdminWebChild; + +type EnsureAdminWebOptions = { + apiUrl: string; + reservedPorts?: number[]; + env?: Record; + enabled?: boolean; + resolveEndpoint?: (options: { + env?: Record; + reservedPorts?: number[]; + }) => AdminWebEndpoint | Promise; + spawnAdminWeb?: ( + apiUrl: string, + endpoint: AdminWebEndpoint, + options: { env?: Record }, + ) => AdminWebChild; + waitForExit?: (child: AdminWebChild) => unknown | Promise; + warn?: (message: string) => void; +}; + +// 同上:apiUrl 等无默认值的解构项未被 JS 推断保留,reservedPorts 被推成 never[]。 +const ensureAdminWeb = ensureAdminWebImpl as unknown as ( + options: EnsureAdminWebOptions, +) => Promise<{ + endpoint: AdminWebEndpoint | null; + child: AdminWebChild | null; +}>; + const expectedDatabase = 'genarrative-game-creator-dev'; const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data'); const expectedExePath = resolve('server-rs/target/debug/api-server.exe'); @@ -40,7 +243,10 @@ const ownedBackend = async () => ({ owners: new Map(), }); -function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) { +function backendState( + spacetimeDataDir?: string, + includeBgfilterWorker = true, +): BackendStateFixture { const instanceId = 'fixture-instance'; const service = (url: string) => ({ status: 'running', @@ -180,9 +386,9 @@ describe('AI 游戏创作配套后端复用门禁', () => { test('后端服务失败时返回具体失败服务,避免外层无限等待', () => { const state = backendState(expectedDataDir); - state.services['bgfilter-worker'].status = 'failed'; - state.services['bgfilter-worker'].exitCode = 1; - state.services['bgfilter-worker'].signal = null; + state.services['bgfilter-worker']!.status = 'failed'; + state.services['bgfilter-worker']!.exitCode = 1; + state.services['bgfilter-worker']!.signal = null; expect(readBackendServiceFailure(state)).toEqual({ serviceName: 'bgfilter-worker', @@ -192,8 +398,8 @@ describe('AI 游戏创作配套后端复用门禁', () => { test('不匹配的旧状态失败记录不会阻断当前后端启动', () => { const state = backendState(resolve('server-rs/.spacetimedb/other/data')); - state.services['bgfilter-worker'].status = 'failed'; - state.services['bgfilter-worker'].exitCode = 1; + state.services['bgfilter-worker']!.status = 'failed'; + state.services['bgfilter-worker']!.exitCode = 1; expect(readBackendServiceFailure(state)).toBeNull(); }); @@ -203,8 +409,8 @@ describe('AI 游戏创作配套后端复用门禁', () => { initialState.updatedAt = '2026-09-04T08:00:00.000Z'; const state = backendState(expectedDataDir); state.updatedAt = '2026-09-04T08:00:01.000Z'; - state.services['bgfilter-worker'].status = 'failed'; - state.services['bgfilter-worker'].exitCode = 98; + state.services['bgfilter-worker']!.status = 'failed'; + state.services['bgfilter-worker']!.exitCode = 98; const child = Object.assign(new EventEmitter(), { exitCode: null, signalCode: null, @@ -426,7 +632,7 @@ describe('AI 游戏创作配套后端归属校验', () => { }); test('端口探测改用 netstat 取监听 PID,不再每次调用 WMI 的 Get-NetTCPConnection', () => { - const spawnImpl = vi.fn((..._args: unknown[]) => ({ + const spawnImpl = vi.fn((_command: string, args: readonly string[]) => ({ status: 0, error: null, stdout: '[]', @@ -434,7 +640,7 @@ describe('AI 游戏创作配套后端归属校验', () => { readWindowsPortOwnerIdentities([8082], { spawnImpl, env: {} }); - const probeArgs = spawnImpl.mock.calls[0][1] ?? []; + const probeArgs = spawnImpl.mock.calls[0]![1] ?? []; const probeScript = String(probeArgs[probeArgs.length - 1] ?? ''); expect(probeScript).toContain('netstat -ano -p tcp'); expect(probeScript).not.toContain('Get-NetTCPConnection'); @@ -470,7 +676,7 @@ describe('AI 游戏创作配套后端归属校验', () => { commandLineTtlMs: 60_000, }; const envAt = (call: number) => - (spawnImpl.mock.calls[call][2] as { env: Record }).env; + (spawnImpl.mock.calls[call]![2] as { env: Record }).env; readWindowsPortOwnerIdentities([3102], options); expect(envAt(0)).toEqual({ GENARRATIVE_QUERY_PORTS: '3102' }); @@ -595,9 +801,10 @@ describe('AI 游戏创作启动子进程生命周期', () => { await waitForFile(stoppedPath); } finally { - if (Number.isInteger(child?.pid)) { + const childPid = child?.pid; + if (Number.isInteger(childPid)) { try { - process.kill(-child.pid, 'SIGKILL'); + process.kill(-childPid!, 'SIGKILL'); } catch { // 测试后代已经退出。 } @@ -747,7 +954,13 @@ describe('AGC 开发态后台 Web', () => { }); test('后台 Vite 指向配套后端且严格使用解析后的端口', () => { - const spawnImpl = vi.fn(() => ({ pid: 4242 })); + const spawnImpl = vi.fn( + ( + _command: string, + _args: string[], + _options: { cwd: string; env: Record }, + ) => ({ pid: 4242 }), + ); startAdminWeb( 'http://127.0.0.1:8084', { @@ -760,7 +973,7 @@ describe('AGC 开发态后台 Web', () => { ); expect(spawnImpl).toHaveBeenCalledTimes(1); - const [command, args, options] = spawnImpl.mock.calls[0]; + const [command, args, options] = spawnImpl.mock.calls[0]!; expect(command).toBe(process.platform === 'win32' ? 'npm.cmd' : 'npm'); expect(args).toEqual([ '--prefix', diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index b3de87823..fc784f551 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -7,7 +7,7 @@ import { describe, expect, test, vi } from 'vitest'; import { readCargoTarget, - resolveEditorFeatures, + resolveEditorFeatures as resolveEditorFeaturesImpl, } from '../scripts/cargo-features.mjs'; import { isProcessGroupAlive, @@ -17,11 +17,91 @@ import { import { buildTauriArguments, buildTauriDevProcessEnv, - prepareBundledResourcesBeforeTauri, - runTauriDev as runTauriDevImpl, + prepareBundledResourcesBeforeTauri as prepareBundledResourcesBeforeTauriImpl, + runTauriDev as runTauriDevRaw, withDevCargoFeatures, } from '../scripts/start-tauri-dev.mjs'; +// scripts/*.mjs 没有 JSDoc:TS 只能按默认值推导签名,可选回调参数被推成具体实现、 +// argv 默认值被推成 never[],与测试注入的 mock / 参数结构不兼容。下面按运行时真实契约 +// 重新声明局部签名并重绑定导入实现,只做类型层校正,调用方式与运行时行为完全不变。 + +type EndpointPortRange = { start: number; end: number; label: string }; + +type DevEndpoint = { + host: string; + port: number; + url: string; + markerUrl: string; + portRange: EndpointPortRange | null; +}; + +type ResolveEditorFeaturesOptions = { + argv?: string[]; + target?: string; + env?: Record; +}; + +// 同上:函数的 argv 默认值 [] 被 JS 推断成 never[],无法接收测试传入的 string[]。 +const resolveEditorFeatures = resolveEditorFeaturesImpl as unknown as ( + options?: ResolveEditorFeaturesOptions, +) => string[]; + +type PrepareBundledResourcesOptions = { + target: string; + features: Iterable; +}; + +type PrepareBundledResourcesBeforeTauriOptions = { + prepare?: (options: PrepareBundledResourcesOptions) => unknown[]; + log?: (message: string) => void; + hostTarget?: string | null; +}; + +// 同上:prepare 在 JS 推断里被收窄为 prepareBundledResources 的完整可选参数签名。 +const prepareBundledResourcesBeforeTauri = + prepareBundledResourcesBeforeTauriImpl as unknown as ( + features: Iterable, + argv?: string[], + options?: PrepareBundledResourcesBeforeTauriOptions, + ) => void; + +type ChildProcessLike = { + pid?: number; + exitCode?: number | null; + signalCode?: string | null; +}; + +type RunTauriDevOptions = { + resolveDevEndpoint?: (options?: { + strictConfigured?: boolean; + }) => DevEndpoint | Promise; + preflight?: (options: { + endpoint: DevEndpoint; + }) => unknown | Promise; + prepareFrontend?: ( + endpoint: DevEndpoint, + options: { + signal: AbortSignal; + onChild: (child: ChildProcessLike) => void; + }, + ) => unknown | Promise; + spawnCli?: ( + argv: string[], + options: { env: Record }, + ) => ChildProcessLike; + waitForCli?: (child: ChildProcessLike) => unknown | Promise; + terminateTree?: (child: ChildProcessLike) => unknown | Promise; + prepareResources?: (...args: unknown[]) => void; +}; + +// 同上:resolveDevEndpoint / preflight / spawnCli / terminateTree 在 JS 推断里被收窄为 +// 具体实现的返回结构,测试注入的 mock 不可直接赋值。 +const runTauriDevImpl = runTauriDevRaw as unknown as ( + argv?: string[], + options?: RunTauriDevOptions, +) => Promise; + const testEndpoint = { host: '127.0.0.1', port: 10005, @@ -227,7 +307,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { } }, 10); `; - let cliChild; + let cliChild: ReturnType | undefined; try { const result = await runTauriDev([], { resolveDevEndpoint: resolveTestEndpoint, @@ -245,9 +325,10 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { expect(result).toBe(42); await waitForFile(stoppedPath); } finally { - if (Number.isInteger(cliChild?.pid)) { + const cliPid = cliChild?.pid; + if (Number.isInteger(cliPid)) { try { - process.kill(-cliChild.pid, 'SIGKILL'); + process.kill(-cliPid!, 'SIGKILL'); } catch { // 进程组已经由启动器收束。 } @@ -283,7 +364,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { } }, 10); `; - let cliChild; + let cliChild: ReturnType | undefined; try { const result = await runTauriDev([], { resolveDevEndpoint: resolveTestEndpoint, @@ -304,11 +385,12 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { }); expect(result).toBe(42); - expect(isProcessGroupAlive(cliChild.pid)).toBe(false); + expect(isProcessGroupAlive(cliChild!.pid)).toBe(false); } finally { - if (Number.isInteger(cliChild?.pid)) { + const cliPid = cliChild?.pid; + if (Number.isInteger(cliPid)) { try { - process.kill(-cliChild.pid, 'SIGKILL'); + process.kill(-cliPid!, 'SIGKILL'); } catch { // 进程组已经由启动器强制收束。 } @@ -417,7 +499,7 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { 'x86_64-pc-windows-msvc', hostTarget, ]); - expect(calls[0][1]).toEqual(windowsFeatures); + expect(calls[0]![1]).toEqual(windowsFeatures); }); test('宿主平台不受声明覆盖时跳过准备,且不为其新增随包支持', () => { @@ -471,12 +553,12 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { expect(prepared.map(([features]) => features)).toEqual([ ['custom-feature'], ]); - expect(spawned[0].slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']); - expect(spawned[0]).not.toContain( + expect(spawned[0]!.slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']); + expect(spawned[0]!).not.toContain( `--features=${windowsFeatures.join(',')}`, ); // 随包资源准备拿到的是最终交给 Tauri 的参数,不是启动器收到的原始参数。 - expect(prepared[0][1]).toEqual(spawned[0]); + expect(prepared[0]![1]).toEqual(spawned[0]!); } finally { vi.unstubAllEnvs(); } @@ -492,14 +574,14 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { expect(result).toBe(0); expect(prepared.map(([features]) => features)).toEqual([[]]); - expect(prepared[0][1]).toEqual([ + expect(prepared[0]![1]).toEqual([ 'dev', '--target', 'x86_64-apple-darwin', '--config', devConfig, ]); - expect(spawned[0]).toEqual(prepared[0][1]); + expect(spawned[0]!).toEqual(prepared[0]![1]); } finally { vi.unstubAllEnvs(); } @@ -515,7 +597,7 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { expect(result).toBe(0); expect(prepared.map(([features]) => features)).toEqual([[]]); - expect(spawned[0]).toEqual([ + expect(spawned[0]!).toEqual([ 'dev', '--config', devConfig, @@ -523,7 +605,7 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { '--', '--features=app-tool', ]); - expect(prepared[0][1]).toEqual(spawned[0]); + expect(prepared[0]![1]).toEqual(spawned[0]!); } finally { vi.unstubAllEnvs(); } @@ -537,7 +619,7 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { expect(result).toBe(0); expect(prepared.map(([features]) => features)).toEqual([[]]); expect( - spawned[0].some((argument) => argument.startsWith('--features')), + spawned[0]!.some((argument) => argument.startsWith('--features')), ).toBe(false); } finally { vi.unstubAllEnvs(); diff --git a/apps/ai-game-creator-shell/tests/tauriEventFake.ts b/apps/ai-game-creator-shell/tests/tauriEventFake.ts index bcc8b4d7d..008145180 100644 --- a/apps/ai-game-creator-shell/tests/tauriEventFake.ts +++ b/apps/ai-game-creator-shell/tests/tauriEventFake.ts @@ -102,8 +102,8 @@ export function createTauriEventFake( flushRegistrationEvals() { const pending = registrations.splice(0, registrations.length); for (const listener of pending) { - registry[listener.event] ??= {}; - registry[listener.event][listener.eventId] = { + const eventRegistry = (registry[listener.event] ??= {}); + eventRegistry[listener.eventId] = { handlerId: listener.handlerId, }; } diff --git a/apps/ai-game-creator-shell/tests/tauriRuntimeFake.ts b/apps/ai-game-creator-shell/tests/tauriRuntimeFake.ts new file mode 100644 index 000000000..74c3e98f7 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/tauriRuntimeFake.ts @@ -0,0 +1,59 @@ +/** + * 测试用 Tauri 全局桥替身:全仓唯一的 `window.__TAURI__` 安装点与类型转换点。 + * + * `Window['__TAURI__']` 声明的是泛型签名(`core.invoke` / `event.listen` 都是泛型函数), + * 而各套件的替身是 `vi.fn` 或具体签名的 mock,二者结构上不兼容。若在每个用例里直接赋值, + * 就得反复写 `as never` / `as unknown as`,类型检查形同虚设;这里把转换收敛到一处, + * 调用方只声明自己真正装了哪些能力。 + * + * 参数用「可调用」下界而不是 `unknown`:非函数的误装(字符串、`undefined`)会在编译期被挡下, + * 同时不与各夹具更具体的 mock 签名冲突;`core` / `event` 都可缺省。 + */ + +export type TauriRuntimeInstall = { + core?: { + invoke?: ( + command: string, + args?: Record, + ) => Promise; + }; + event?: { + listen?: ( + event: string, + handler: (event: { payload: unknown }) => void, + ) => Promise<() => void>; + }; +}; + +export function installTauriRuntime(runtime: TauriRuntimeInstall) { + window.__TAURI__ = runtime as unknown as Window['__TAURI__']; +} + +/** 清掉替身,供 `afterEach` / `beforeEach` 复位(等价于仓库既有的 `delete window.__TAURI__`)。 */ +export function resetTauriRuntime() { + delete window.__TAURI__; +} + +/** + * 测试用 Tauri 事件注册表:只登记 `event.listen` 的回调并按事件名派发。 + * + * 不碰 `window.__TAURI__`、不做类型转换;安装由 `installTauriRuntime` 负责,需要事件桥的 + * 用例把 `bridge.listen` 交给它即可。 + */ +export function createTauriEventBridge() { + const listeners = new Map void>(); + return { + listen: async ( + event: string, + handler: (event: { payload: unknown }) => void, + ) => { + listeners.set(event, handler); + return () => { + listeners.delete(event); + }; + }, + emit: (event: string, payload: unknown) => { + listeners.get(event)?.({ payload }); + }, + }; +} diff --git a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx index 5e3d3086d..83583e196 100644 --- a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx +++ b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx @@ -2,9 +2,11 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import { beforeAll, describe, expect, it, vi } from 'vitest'; +import type { InitLocalProjectResult } from '../src/app/types'; import { computeTemplateGridLayout } from '../src/features/template-library/templateLibraryGrid'; import type { GameTemplateEntry, + InstalledGameTemplate, TemplateLibraryFilters, } from '../src/features/template-library/templateLibraryModel'; import { @@ -285,8 +287,16 @@ describe('TemplateLibraryView', () => { }); it('starts a download and a template project from the card actions', () => { - const downloadTemplate = vi.fn(async () => undefined); - const createProjectFromTemplate = vi.fn(async () => undefined); + // 这两个 mock 只用于断言「被调用」,视图会 `.catch` 掉结果并丢弃; + // 运行时仍返回已 resolve 的 undefined,类型层用 `as unknown as` 补齐合同返回值。 + const downloadTemplate = vi.fn< + [template: GameTemplateEntry], + Promise + >(async () => undefined as unknown as InstalledGameTemplate); + const createProjectFromTemplate = vi.fn< + [template: GameTemplateEntry], + Promise + >(async () => undefined as unknown as InitLocalProjectResult); render( { it('rejects an empty image list without invoking the command', async () => { @@ -25,10 +28,15 @@ describe('ui design resource bridge', () => { committedProjectRevision: 7, }; const invoke = vi.fn().mockResolvedValue(created); + // 真实类型用「省略 path」表达没有路径,而本用例的调用断言固定了 `path: null`; + // 结构上无法用该联合类型表达,只能断言收敛,运行时载荷原样保留。 + const images = [ + { assetId: 'image-a', path: null }, + ] as unknown as UiDesignDocImageReference[]; const result = await createUiDesignDocFromImages({ projectPath: '/project', expectedProjectId: 'project-1', - images: [{ assetId: 'image-a', path: null }], + images, invoke, }); expect(invoke).toHaveBeenCalledWith('create_ui_design_doc_from_images', { diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 6575d7c54..62a4c6243 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -37,6 +37,7 @@ import type { IUiDesignStateStore, UiDesignStateSnapshot, } from '../src/features/ui-editor/uiDesignStateStore'; +import type { UiEditorOperationResult } from '../src/features/ui-editor/useUiEditorState'; import UiEditorPage from '../src/view/ui-editor'; import { useUiEditorSession } from '../src/view/ui-editor/useUiEditorPage'; @@ -92,11 +93,24 @@ function node(id: string, children: UiNode[] = []): UiNode { source: 'System', }, component: null, - children_display_mode: undefined, + children_display_mode: 'Stack', children, + // 真实 Node 现在要求 `offset`,但本 fixture 是「编辑器载入前」的历史形态: + // 运行期必须保持缺省,PreviewWorkspace 才会按旧口径跳过布局;这里只做类型收敛。 + offset: undefined as unknown as UiNode['offset'], }; } +/** + * `insertNode` 返回判别联合 `{ ok: true; value } | { ok: false; reason }`,失败分支没有 + * `value`。测试只取成功分支的节点 id,与原来的 `?.value` 在运行期等价。 + */ +function insertedNodeId( + result: UiEditorOperationResult | undefined, +): string | undefined { + return result?.ok ? result.value : undefined; +} + function stateWithPages(pageIds: string[]): State { return { ui_design_images: Object.fromEntries( @@ -224,11 +238,13 @@ describe('UiEditorPage', () => { expect.objectContaining({ scopeId: expect.any(String) }), ), ); - const firstScopeId = vi - .mocked(invoke) - .mock.calls.find( - ([command]) => command === 'read_local_project_image_preview', - )?.[1]?.scopeId; + const firstScopeId = ( + vi + .mocked(invoke) + .mock.calls.find( + ([command]) => command === 'read_local_project_image_preview', + )?.[1] as Record | undefined + )?.scopeId; hook.rerender({ resourceId: 'ui-resource-b' }); await waitFor(() => @@ -246,12 +262,14 @@ describe('UiEditorPage', () => { ), ).toHaveLength(4), ); - const secondScopeId = vi - .mocked(invoke) - .mock.calls.filter( - ([command]) => command === 'read_local_project_image_preview', - ) - .at(-1)?.[1]?.scopeId; + const secondScopeId = ( + vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === 'read_local_project_image_preview', + ) + .at(-1)?.[1] as Record | undefined + )?.scopeId; hook.unmount(); @@ -461,10 +479,8 @@ describe('UiEditorPage', () => { expect(play).toHaveBeenCalledTimes(4); expect(scrollSpy).toHaveBeenCalledTimes(4); } finally { - delete (Element.prototype as Element & { getAnimations?: unknown }) - .getAnimations; - delete (Element.prototype as Element & { scrollIntoView?: unknown }) - .scrollIntoView; + delete (Element.prototype as { getAnimations?: unknown }).getAnimations; + delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView; } }); @@ -511,8 +527,7 @@ describe('UiEditorPage', () => { }); } } finally { - delete (Element.prototype as Element & { scrollIntoView?: unknown }) - .scrollIntoView; + delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView; } }); @@ -626,8 +641,12 @@ describe('UiEditorPage', () => { let parentId: string | undefined; let childId: string | undefined; act(() => { - parentId = result.current.input.insertNode(rootId, 'page')?.value; - childId = result.current.input.insertNode(parentId!, 'page')?.value; + parentId = insertedNodeId( + result.current.input.insertNode(rootId, 'page'), + ); + childId = insertedNodeId( + result.current.input.insertNode(parentId!, 'page'), + ); result.current.input.selectNode(childId!); }); @@ -706,7 +725,9 @@ describe('UiEditorPage', () => { const rootId = 'page-root'; let otherNodeId: string | undefined; act(() => { - otherNodeId = result.current.input.insertNode(rootId, 'page')?.value; + otherNodeId = insertedNodeId( + result.current.input.insertNode(rootId, 'page'), + ); result.current.input.selectNode(rootId); result.current.input.highlightStatusField(rootId, 'layout_status'); }); @@ -745,7 +766,9 @@ describe('UiEditorPage', () => { const rootId = 'page-root'; let otherNodeId: string | undefined; act(() => { - otherNodeId = result.current.input.insertNode(rootId, 'page')?.value; + otherNodeId = insertedNodeId( + result.current.input.insertNode(rootId, 'page'), + ); result.current.input.focusNode('page', rootId); result.current.input.highlightStatusField(rootId, 'layout_status'); }); @@ -774,13 +797,18 @@ describe('UiEditorPage', () => { let childBId: string | undefined; let childBDescendantId: string | undefined; act(() => { - parentId = result.current.input.insertNode(rootId, 'page')?.value; - childAId = result.current.input.insertNode(parentId!, 'page')?.value; - childBId = result.current.input.insertNode(parentId!, 'page')?.value; - childBDescendantId = result.current.input.insertNode( - childBId!, - 'page', - )?.value; + parentId = insertedNodeId( + result.current.input.insertNode(rootId, 'page'), + ); + childAId = insertedNodeId( + result.current.input.insertNode(parentId!, 'page'), + ); + childBId = insertedNodeId( + result.current.input.insertNode(parentId!, 'page'), + ); + childBDescendantId = insertedNodeId( + result.current.input.insertNode(childBId!, 'page'), + ); }); act(() => result.current.input.selectNode(parentId!)); act(() => result.current.inspector.setNodeChildrenDisplayMode('Exclusive')); @@ -1068,7 +1096,7 @@ describe('UiEditorPage', () => { ); expect(calls).toHaveLength(count); if (count) - expect(calls[0][1]).toMatchObject({ + expect(calls[0]?.[1]).toMatchObject({ projectPath: '/tmp/ui-editor', saveSource, changed: status === 'saved', diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 585e308ba..1fbbd8067 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -41,6 +41,7 @@ function node(id: string, children: UiNode[] = []): UiNode { component: null, children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -61,20 +62,21 @@ const resources = { }; function renderTree( - renderMode: 'editor-overlay' | 'final-preview', hiddenNodeIds: ReadonlySet, options: { showFrame?: boolean; + showComponent?: boolean; selectedNodeId?: string | null; onSelectNode?: ReturnType; onNodeContextMenu?: ReturnType; + onSelectExclusiveChild?: (nodeId: string) => void; } = {}, ) { return render( , ); @@ -95,7 +98,7 @@ function renderTree( describe('UI tree preview visibility', () => { it('disables native text selection on preview node layers', () => { - const rendered = renderTree('editor-overlay', new Set()); + const rendered = renderTree(new Set()); expect( rendered.container @@ -106,7 +109,7 @@ describe('UI tree preview visibility', () => { it('does not use click alone to select directly draggable nodes', () => { const onSelectNode = vi.fn(); - const rendered = renderTree('editor-overlay', new Set(), { onSelectNode }); + const rendered = renderTree(new Set(), { onSelectNode }); fireEvent.click( rendered.container.querySelector('[data-node-id="child"]')!, ); @@ -187,8 +190,8 @@ describe('UI tree preview visibility', () => { const rendered = render( { onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} onNodeResizePointerCancel={vi.fn()} + onSelectExclusiveChild={vi.fn()} viewportScale={1} />, ); @@ -219,7 +223,7 @@ describe('UI tree preview visibility', () => { }); it('shows final-preview node frames and resize handles only when requested', () => { - const withoutFrames = renderTree('final-preview', new Set()); + const withoutFrames = renderTree(new Set()); const plainChild = withoutFrames.container.querySelector( '[data-node-id="child"]', ) as HTMLDivElement; @@ -227,7 +231,7 @@ describe('UI tree preview visibility', () => { expect(screen.queryByLabelText('nw 调整控制点')).toBeNull(); withoutFrames.unmount(); - const withFrames = renderTree('final-preview', new Set(), { + const withFrames = renderTree(new Set(), { showFrame: true, selectedNodeId: 'child', }); @@ -240,7 +244,7 @@ describe('UI tree preview visibility', () => { // root 是整树容器:它自己即使被选中也不能出现 resize 控制点,子节点仍按上面那 8 个。 withFrames.unmount(); - const withRootSelected = renderTree('final-preview', new Set(), { + const withRootSelected = renderTree(new Set(), { showFrame: true, selectedNodeId: 'root', }); @@ -251,7 +255,7 @@ describe('UI tree preview visibility', () => { }); it('hides a node subtree in editor overlay and final preview', () => { - const editorOverlay = renderTree('editor-overlay', new Set(['child'])); + const editorOverlay = renderTree(new Set(['child'])); expect( editorOverlay.container.querySelector('[data-node-id="root"]'), ).toBeTruthy(); @@ -262,7 +266,7 @@ describe('UI tree preview visibility', () => { editorOverlay.container.querySelector('[data-node-id="grandchild"]'), ).toBeNull(); - const finalPreview = renderTree('final-preview', new Set(['root'])); + const finalPreview = renderTree(new Set(['root'])); expect(finalPreview.container.querySelector('[data-node-id]')).toBeNull(); }); @@ -275,8 +279,8 @@ describe('UI tree preview visibility', () => { render( { onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} onNodeResizePointerCancel={vi.fn()} + onSelectExclusiveChild={vi.fn()} viewportScale={1} />, ); @@ -349,8 +354,8 @@ describe('UI tree preview visibility', () => { render( { const editorOverlay = render( { onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} onNodeResizePointerCancel={vi.fn()} + onSelectExclusiveChild={vi.fn()} viewportScale={1} />, ); @@ -414,7 +420,7 @@ describe('UI tree preview visibility', () => { ); editorOverlay.unmount(); - const finalPreview = renderTree('final-preview', new Set(), { + const finalPreview = renderTree(new Set(), { onNodeContextMenu, }); fireEvent.contextMenu( diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index e7e5d1c85..d4b5ec699 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -84,7 +84,9 @@ function nodeWithSprite(id: string): Node { image_type: { Simple: { preserve_aspect: false } }, }, }, + children_display_mode: 'Stack', children: [], + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -116,6 +118,7 @@ function pageRoot(id: string, children: Node[] = []): Node { component: null, children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -465,6 +468,7 @@ describe('useUiEditorState', () => { component: null, children_display_mode: 'Stack', children: [nodeWithSprite('panel')], + offset: { min: [0, 0], max: [0, 0] }, }, }, ], @@ -566,13 +570,18 @@ describe('useUiEditorState', () => { }; const { result } = renderHook(() => useUiEditorState(initial)); + // 夹具根节点就是文本组件;先收窄联合类型,后面才能展开出完整 TextComponent。 + const rootComponent = initial.ui_trees[0]!.root.component; + if (!rootComponent || !('Text' in rootComponent)) { + throw new Error('夹具根节点必须是文本组件'); + } + const existingText = rootComponent.Text; + act(() => { expect( result.current.setNodeComponent('page', 'text-root', { Text: { - ...('Text' in initial.ui_trees[0]!.root.component! - ? initial.ui_trees[0]!.root.component!.Text - : {}), + ...existingText, font: { Bound: 'body' }, }, }), diff --git a/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts b/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts index fe73af837..18e53eea4 100644 --- a/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts +++ b/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts @@ -40,7 +40,7 @@ function node( source: 'System', }, component: null, - children_display_mode: undefined, + children_display_mode: 'Stack', offset: { min: [0, 0], max: [0, 0] }, children: [], }; diff --git a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts index 2795e71f1..cae393966 100644 --- a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts +++ b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts @@ -14,6 +14,7 @@ function node(id: string, children: Node[] = []): Node { component: null, children_display_mode: 'Stack', children, + offset: {} as Node['offset'], }; } diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts index 94287a7cb..b7812e2cd 100644 --- a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts @@ -12,7 +12,7 @@ import { installUnhandledRejectionBridge } from './unhandledRejectionBridge'; const installed: Array<() => void> = []; const processListeners: Array<(reason: unknown, promise: unknown) => void> = []; const windowListeners: Array<(event: Event) => void> = []; -let parkedListeners: Array<(reason: unknown, promise: unknown) => void> = []; +let parkedListeners: NodeJS.UnhandledRejectionListener[] = []; afterEach(() => { while (installed.length > 0) installed.pop()?.(); diff --git a/apps/ai-game-creator-shell/tests/unityProjectOpen.test.tsx b/apps/ai-game-creator-shell/tests/unityProjectOpen.test.tsx index 82b5d950c..713ccee5a 100644 --- a/apps/ai-game-creator-shell/tests/unityProjectOpen.test.tsx +++ b/apps/ai-game-creator-shell/tests/unityProjectOpen.test.tsx @@ -5,9 +5,10 @@ import { afterEach, expect, test, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { buildRecentProjectRows } from '../src/features/app-shell/model'; import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); }); test.each([false, true])( @@ -42,13 +43,13 @@ test.each([false, true])( return { activeRuntime: 'game' }; throw new Error(`意外调用:${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const setLauncherView = vi.fn(); const { result } = renderHook(() => useHomeProjectCreation({ setStatus: vi.fn(), + showTransientNotice: vi.fn(), setLauncherView, - setAgentChatProjectPath: vi.fn(), rememberRecentWorkspace: vi.fn(), }), ); diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index f135a1db6..77fa628e0 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { act, renderHook } from '@testing-library/react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; import { describe, expect, it, vi } from 'vitest'; import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; @@ -34,7 +35,9 @@ function node(id: string, children: UiNode[] = []): UiNode { source: 'System', }, component: null, + children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -82,7 +85,8 @@ function pointerEvent( preventDefault: vi.fn(), shiftKey: false, stopPropagation: vi.fn(), - } as never; + // 测试只造出 hook 用到的少数字段,与完整的 React 合成事件结构上不可兼容,故用 as unknown as 收敛。 + } as unknown as ReactPointerEvent; } function canvas() { diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index 4f8f84402..bbd90923d 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -21,6 +21,7 @@ import { inspectProjectResourceCanvasLayoutRead, useProjectResourceCanvasLayout, } from '../src/view/project-development/useProjectResourceCanvasLayout'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const projectId = 'layout-hook-project'; const projectPath = '/tmp/layout-hook-project'; @@ -78,9 +79,22 @@ function automaticPosition( }; } +/** + * 测试装配用的 Tauri invoke 桩入口。 + * + * `TauriInvoke` 是泛型函数签名,而 `vi.fn` 返回的是带具体返回类型的 Mock, + * 具体签名无法赋给泛型签名;这里集中做一次桥接,避免每个用例重复断言。 + */ +function installTauriInvoke( + invoke: (command: string, args?: Record) => Promise, +): void { + // 泛型签名与具体 Mock 结构上不可兼容,这里显式转换以完成测试装配。 + installTauriRuntime({ core: { invoke } }); +} + afterEach(() => { cleanup(); - window.__TAURI__ = undefined; + resetTauriRuntime(); vi.restoreAllMocks(); }); @@ -151,7 +165,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const shallowResource = resource('resource-a'); const deepResource = { ...shallowResource, dependencyDepth: 2 }; const { result, rerender } = renderHook( @@ -189,7 +203,7 @@ describe('useProjectResourceCanvasLayout', () => { it('can render an unpersisted fallback after graph failure without becoming settled', () => { const invoke = vi.fn(); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -234,7 +248,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); renderHook(() => useProjectResourceCanvasLayout({ @@ -300,7 +314,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -363,7 +377,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { rerender } = renderHook( ({ topology }) => useProjectResourceCanvasLayout({ @@ -416,7 +430,7 @@ describe('useProjectResourceCanvasLayout', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -462,7 +476,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -518,7 +532,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ resources }: { resources: ResourceCanvasItem[] }) => useProjectResourceCanvasLayout({ @@ -600,7 +614,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ resources }: { resources: ResourceCanvasItem[] }) => useProjectResourceCanvasLayout({ @@ -696,7 +710,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -760,7 +774,7 @@ describe('useProjectResourceCanvasLayout', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -840,7 +854,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const setTimeoutSpy = vi.spyOn(window, 'setTimeout'); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -927,7 +941,7 @@ describe('useProjectResourceCanvasLayout', () => { }); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ mode }: { mode: ProjectResourceCanvasLayoutMode }) => useProjectResourceCanvasLayout({ @@ -936,7 +950,9 @@ describe('useProjectResourceCanvasLayout', () => { mode, resources: [resourceA], }), - { initialProps: { mode: 'dependency' as const } }, + { + initialProps: { mode: 'dependency' as ProjectResourceCanvasLayoutMode }, + }, ); await waitFor(() => expect(readResolvers.has('dependency')).toBe(true)); rerender({ mode: 'type' }); @@ -998,7 +1014,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ mode }: { mode: ProjectResourceCanvasLayoutMode }) => useProjectResourceCanvasLayout({ @@ -1007,7 +1023,9 @@ describe('useProjectResourceCanvasLayout', () => { mode, resources: [resourceA], }), - { initialProps: { mode: 'dependency' as const } }, + { + initialProps: { mode: 'dependency' as ProjectResourceCanvasLayoutMode }, + }, ); await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10)); @@ -1061,7 +1079,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1120,7 +1138,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1225,7 +1243,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1290,7 +1308,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1326,7 +1344,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1397,7 +1415,14 @@ describe('resource canvas layout read report', () => { const counts = inspectProjectResourceCanvasLayoutRead( layoutWith([ { resourceId: 'a', section: 'code', x: 1, y: 2, manuallyPlaced: true }, - { resourceId: 'b', section: 'nope', x: 3, y: 4, manuallyPlaced: true }, + { + resourceId: 'b', + // 用例刻意喂入无法识别的分区取值,类型层收口到真实联合但不改运行时串。 + section: 'nope' as ProjectResourceCanvasPosition['section'], + x: 3, + y: 4, + manuallyPlaced: true, + }, ]), [ { ...resource('a'), category: 'document' }, @@ -1480,7 +1505,7 @@ describe('布局落盘 / 读盘失败的原因不再被吞掉', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -1520,7 +1545,7 @@ describe('布局落盘 / 读盘失败的原因不再被吞掉', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -1581,7 +1606,7 @@ describe('资源画布整理与批量坐标写入', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); return { invoke, updates }; } @@ -1631,7 +1656,7 @@ describe('资源画布整理与批量坐标写入', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); return { invoke, updates, @@ -2176,7 +2201,7 @@ describe('资源画布整理与批量坐标写入', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ mode }: { mode: ProjectResourceCanvasLayoutMode }) => useProjectResourceCanvasLayout({ @@ -2185,7 +2210,7 @@ describe('资源画布整理与批量坐标写入', () => { mode, resources: [documentA, documentB], }), - { initialProps: { mode: 'type' as const } }, + { initialProps: { mode: 'type' as ProjectResourceCanvasLayoutMode } }, ); await waitFor(() => expect(result.current.settled).toBe(true)); typeWrites.length = 0; @@ -2524,7 +2549,7 @@ describe('资源画布整理与批量坐标写入', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 6db7c7e3a..cc4472fd5 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -8,7 +8,15 @@ import { waitFor, } from '@testing-library/react'; import React from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest'; import { isProjectResourcePreviewCancellation } from '../src/services/projectResourcePreviewTransport'; import { createResourceCanvasPrefetchScopeKey } from '../src/view/project-development/resourceCanvasController'; @@ -23,6 +31,7 @@ import { nextPreviewJob, useProjectResourceCardPreviews as useProjectResourceCardPreviewsUnderTest, } from '../src/view/project-development/useProjectResourceCardPreviews'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; type PreviewHookInput = Parameters< typeof useProjectResourceCardPreviewsUnderTest @@ -126,7 +135,7 @@ beforeEach(() => { afterEach(() => { cleanup(); - window.__TAURI__ = undefined; + resetTauriRuntime(); vi.restoreAllMocks(); if (originalCreateObjectUrl) { Object.defineProperty(URL, 'createObjectURL', originalCreateObjectUrl); @@ -158,7 +167,7 @@ describe('useProjectResourceCardPreviews', () => { : {}), }), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const resources = [json]; const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( @@ -209,14 +218,14 @@ describe('useProjectResourceCardPreviews', () => { content: 'const answer = 42;', }), ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: async ( command: string, args?: Record, ) => (await invoke(command, args)) as Result, }, - }; + }); const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/document-preview', @@ -260,7 +269,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -294,7 +303,7 @@ describe('useProjectResourceCardPreviews', () => { mediaType: 'image/gif', }); const invoke = vi.fn(async () => preview(art.path, 'image/gif')); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -335,8 +344,12 @@ describe('useProjectResourceCardPreviews', () => { // 6 类资产分类轴落地后的画布栏目取值,与线上读取分支是两个轴。 category: 'unclassified', }); - const invoke = vi.fn(async () => preview(art.path, 'image/gif')); - window.__TAURI__ = { core: { invoke } }; + // 补上 IPC 参数签名:下面要按「命令 + 入参」读回调用,空元组会取不到 index 1。 + const invoke = vi.fn( + async (_command: string, _args?: Record) => + preview(art.path, 'image/gif'), + ); + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -371,7 +384,7 @@ describe('useProjectResourceCardPreviews', () => { category: 'audio', }); const invoke = vi.fn(async () => preview(audio.path, 'audio/mpeg')); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -479,7 +492,7 @@ describe('useProjectResourceCardPreviews', () => { new Error('读取项目媒体资源失败:temporarily busy'), ) .mockResolvedValueOnce(preview(art.path)); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ @@ -520,7 +533,7 @@ describe('useProjectResourceCardPreviews', () => { .mockRejectedValue( new Error('image.inspect 图片结构无效:assets/broken.png'), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ @@ -564,11 +577,18 @@ describe('useProjectResourceCardPreviews', () => { const secondRead = deferred>(); const thirdRead = deferred>(); const blockedReads = [firstRead, secondRead, thirdRead]; - const invoke = vi.fn((_: string, args?: Record) => { + // 显式标注类型:mock 内部要读 `invoke.mock.calls`,有注解后不再形成自引用推断环。 + const invoke: Mock< + [command: string, args?: Record], + Promise> + > = vi.fn((_: string, args?: Record) => { const blocked = blockedReads[invoke.mock.calls.length - 1]; - return blocked?.promise ?? new Promise(() => undefined); + return ( + blocked?.promise ?? + new Promise>(() => undefined) + ); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ @@ -636,7 +656,7 @@ describe('useProjectResourceCardPreviews', () => { const invoke = vi.fn(async (_: string, args?: Record) => preview(String(args?.relativePath ?? ''), 'video/mp4'), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, unmount } = renderHook(() => useProjectResourceCardPreviews({ @@ -676,7 +696,7 @@ describe('useProjectResourceCardPreviews', () => { const invoke = vi.fn(async (_: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender, unmount } = renderHook( (resources: ProjectResource[]) => @@ -766,7 +786,7 @@ describe('useProjectResourceCardPreviews', () => { pending.push({ path: String(args?.relativePath ?? ''), read }); return read.promise; }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( (props: { @@ -858,13 +878,15 @@ describe('useProjectResourceCardPreviews', () => { it('preserves the preview identity, dimensions, and native scope for a mode-only change', async () => { const art = resource('mode-switch-art'); - const invoke = vi.fn(async (command: string) => { - if (command === 'cancel_local_project_resource_preview_scope') { - return undefined; - } - return preview(art.path); - }); - window.__TAURI__ = { core: { invoke } }; + const invoke = vi.fn( + async (command: string, _args?: Record) => { + if (command === 'cancel_local_project_resource_preview_scope') { + return undefined; + } + return preview(art.path); + }, + ); + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender, unmount } = renderHook( (mode: 'dependency' | 'type') => @@ -876,7 +898,8 @@ describe('useProjectResourceCardPreviews', () => { canvasRef, eagerPreviewLimit: 1, }), - { initialProps: 'dependency' as const }, + // 标注为 union:`as const` 会把 renderHook 的 Props 收窄成单个字面量,rerender('type') 报错。 + { initialProps: 'dependency' as 'dependency' | 'type' }, ); const firstIdentity = result.current.identityByResourceId.get(art.id)!; await waitFor(() => @@ -920,7 +943,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( (previewVersion: string) => @@ -967,7 +990,7 @@ describe('useProjectResourceCardPreviews', () => { it('rejects an A-B-A late result even when scope and identity match again', async () => { const art = resource('aba-art'); const pending: DeferredPreview[] = []; - const invoke = vi.fn((command: string) => { + const invoke = vi.fn((command: string, _args?: Record) => { if (command === 'cancel_local_project_resource_preview_scope') { return Promise.resolve(); } @@ -975,7 +998,7 @@ describe('useProjectResourceCardPreviews', () => { pending.push(read); return read.promise; }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( (props: { projectPath: string; projectId: string }) => @@ -1050,7 +1073,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const intersectionRoot = document.createElement('div'); const callbacks: IntersectionObserverCallback[] = []; @@ -1124,7 +1147,11 @@ describe('useProjectResourceCardPreviews', () => { target: card, isIntersecting: true, intersectionRatio: 1, - } as IntersectionObserverEntry, + boundingClientRect: card.getBoundingClientRect(), + intersectionRect: card.getBoundingClientRect(), + rootBounds: null, + time: 0, + }, ], {} as IntersectionObserver, ); @@ -1241,7 +1268,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); // root 在首次渲染时还没有挂上(等价于画本容器尚未就绪)。 const canvasRef: { current: HTMLDivElement | null } = { current: null }; const harness = installIntersectionObserver(); @@ -1306,7 +1333,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef: { current: HTMLDivElement | null } = { current: null }; const harness = installIntersectionObserver(); @@ -1369,7 +1396,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const callbacks: IntersectionObserverCallback[] = []; @@ -1513,7 +1540,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef: { current: HTMLDivElement | null } = { current: null }; const harness = installIntersectionObserver(); @@ -1556,7 +1583,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const harness = installIntersectionObserver(); @@ -1664,7 +1691,7 @@ describe('useProjectResourceCardPreviews', () => { const held = deferred>(); const invoke = vi.fn(async () => held.promise); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( @@ -1744,7 +1771,7 @@ describe('useProjectResourceCardPreviews', () => { }); const held = deferred>(); const invoke = vi.fn(async () => held.promise); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( @@ -1822,7 +1849,7 @@ describe('预览队列上限吞掉按需请求时的可观测性', () => { ); // 所有读取都挂住:队列只进不出,才能在用例里稳定压满。 const invoke = vi.fn(() => new Promise(() => undefined)); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const { result } = renderHook(() => @@ -2090,7 +2117,7 @@ describe('冷启动首屏的放行范围与放行顺序', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(PreviewBandHarness, { @@ -2130,7 +2157,7 @@ describe('冷启动首屏的放行范围与放行顺序', () => { // 登记顺序故意先余量圈、后视口内:单趟入队会把这个顺序原样写进队列。 const resources = [...marginOnly, ...inViewport]; const invoke = vi.fn(() => new Promise(() => undefined)); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const harness = installRecordingIntersectionObserver(); const controllerRef: { current: PreviewController | null } = { current: null, @@ -2203,7 +2230,7 @@ describe('预览缓存驱逐必须避开可见卡', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -2283,7 +2310,7 @@ describe('预览缓存驱逐必须避开可见卡', () => { const invoke = vi.fn(async (_: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ diff --git a/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx b/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx index 9cbda5a08..f1a941123 100644 --- a/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx +++ b/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx @@ -272,7 +272,7 @@ describe('模板库灰度会话', () => { setLauncherView, rememberRecentWorkspace, setStatus: vi.fn(), - setAgentChatProjectPath: vi.fn(), + showTransientNotice: vi.fn(), }); const library = useTemplateLibrary({ userId: 'user-a', diff --git a/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts b/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts index 8cde18820..d634040ef 100644 --- a/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts +++ b/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts @@ -32,17 +32,18 @@ function getCssBlock(source: string, selector: string) { function getCssVariable(block: string, variableName: string) { const match = block.match(new RegExp(`${variableName}:\\s*([^;]+);`)); expect(match, `${variableName} should exist`).not.toBeNull(); - return match![1].trim(); + return (match?.[1] ?? '').trim(); } function parseCssColor(source: string): Rgba { const value = source.trim(); const hex = value.match(/^#([\da-f]{6})$/i); if (hex) { + const hexValue = hex[1] ?? ''; return [ - Number.parseInt(hex[1].slice(0, 2), 16), - Number.parseInt(hex[1].slice(2, 4), 16), - Number.parseInt(hex[1].slice(4, 6), 16), + Number.parseInt(hexValue.slice(0, 2), 16), + Number.parseInt(hexValue.slice(2, 4), 16), + Number.parseInt(hexValue.slice(4, 6), 16), 1, ]; } @@ -51,7 +52,7 @@ function parseCssColor(source: string): Rgba { if (!functional) { throw new Error(`unsupported color: ${value}`); } - const parts = functional[1] + const parts = (functional[1] ?? '') .replace('/', ' ') .split(/[,\s]+/) .filter(Boolean); @@ -87,7 +88,11 @@ function contrastRatio(first: Rgba, second: Rgba) { ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; }); - return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + return ( + (channels[0] ?? 0) * 0.2126 + + (channels[1] ?? 0) * 0.7152 + + (channels[2] ?? 0) * 0.0722 + ); }; const firstLuminance = luminance(first); diff --git a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx index ea48aa7f5..c703729f1 100644 --- a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx @@ -18,6 +18,7 @@ import { } from '../../../packages/shared/src/contracts/gameCreationApp'; import type { ProjectChatComponentProps } from '../src/features/app-shell/model'; import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const PROJECT_PATH = '/tmp/manifest-merge-notice-project'; const PROJECT_ID = 'local-project-draft'; @@ -68,7 +69,7 @@ function manifestWithAsset(assetId: string | null): GameCreationAppManifest { value.assets = [ { id: assetId, - kind: 'upload', + kind: 'image', mediaType: 'image/png', localPath: `assets/uploads/${assetId}.png`, source: { kind: 'uploaded' }, @@ -83,7 +84,10 @@ let diskManifest = manifestWithAsset(null); function installInvokeMock() { const invoke = vi.fn( - async (command: string, args?: Record) => { + async ( + command: string, + args?: Record, + ): Promise => { switch (command) { case 'read_game_creator_app_config': return { config: { selectedModelId: 'quality' } }; @@ -123,7 +127,7 @@ function installInvokeMock() { } }, ); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); return invoke; } @@ -231,7 +235,7 @@ describe('清单快照被拒收时的用户可见性与恢复', () => { afterEach(() => { cleanup(); - window.__TAURI__ = undefined; + resetTauriRuntime(); vi.restoreAllMocks(); }); diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx index 49e76f169..208ceeecc 100644 --- a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx @@ -18,6 +18,7 @@ import { render, testAuthUser, } from './appSurface/harness'; +import { resetTauriRuntime } from './tauriRuntimeFake'; const homeProjectOverride = vi.hoisted(() => ({ openProject: null as @@ -78,7 +79,7 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且 ); - delete window.__TAURI__; + resetTauriRuntime(); const rendered = render(view()); try { await act(async () => { diff --git a/apps/ai-game-creator-shell/tsconfig.tests.json b/apps/ai-game-creator-shell/tsconfig.tests.json new file mode 100644 index 000000000..9b4e22b48 --- /dev/null +++ b/apps/ai-game-creator-shell/tsconfig.tests.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "allowImportingTsExtensions": true, + "allowJs": true, + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src", "tests", "vite.config.ts"] +} diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 0b8d4c958..f9dd352eb 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -157,7 +157,12 @@ http { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + try_files $uri /index.html =404; + } + + # 收银台深链 `/pay/`:只放行裸前缀会让真实收银台链接落到默认 location 变 404。 + location ~* "^/pay/[^/]+/?$" { try_files $uri /index.html =404; } # END GENARRATIVE MAIN SPA ROUTES diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index c931fc687..e022e295c 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -107,4 +107,4 @@ curl -sSI -H 'Accept-Encoding: br' \ - 发行入口不使用 Cookie:边缘转发前设置 `proxy_set_header Cookie ""`;`api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。 - 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。 - 审核通过时 `api-server` 按 gameId 派生同源路径 `/games//` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。 -- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)。历史上的独立来源模板与专属门禁已随同源方案上线删除。 +- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index 49b76e619..e2879dff2 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -206,7 +206,19 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri /index.html =404; + } + + # 收银台深链 `/pay/`:token 由前端从最后一个路径段读取(payment.rs 生成该链接), + # 只放行裸前缀会让真实收银台链接落到默认 location 变 404;这里只放行「/pay/ + 恰好一个路径段」。 + location ~* "^/pay/[^/]+/?$" { error_page 503 /maintenance.html; if ($genarrative_maintenance) { diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index ebf5d4520..15d8c4d04 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -234,7 +234,19 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri /index.html =404; + } + + # 收银台深链 `/pay/`:token 由前端从最后一个路径段读取(payment.rs 生成该链接), + # 只放行裸前缀会让真实收银台链接落到默认 location 变 404;这里只放行「/pay/ + 恰好一个路径段」。 + location ~* "^/pay/[^/]+/?$" { error_page 503 /maintenance.html; if ($genarrative_maintenance) { diff --git a/deploy/pingora/nginx-route-parity.matrix.json b/deploy/pingora/nginx-route-parity.matrix.json index 1d3f8b993..7df7ff384 100644 --- a/deploy/pingora/nginx-route-parity.matrix.json +++ b/deploy/pingora/nginx-route-parity.matrix.json @@ -311,6 +311,20 @@ }, "docs": ["主站 SPA allowlist", "失败回退 `/index.html`"] }, + { + "id": "pay_checkout_spa_fallback", + "samplePath": "/pay/checkout-token", + "expect": { + "kind": "static", + "root": "web", + "mode": "spa_fallback" + }, + "nginx": { + "production": ["location ~* \"^/pay/[^/]+/?$\""], + "development": ["location ~* \"^/pay/[^/]+/?$\""] + }, + "docs": ["收银台深链 `/pay/`", "前缀 + 恰好一个路径段"] + }, { "id": "games_spa_fallback", "samplePath": "/games/detail", diff --git a/docs/README.md b/docs/README.md index 5bd922b16..14d0d17fa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ - [后台游戏评价管理合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同):查找、分页、隐藏/恢复/删除、必填原因、统计与个人状态联动;已实现并通过本地验证,待用户验收,未部署。 - [后台游戏评价管理里程碑](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】后台游戏评价管理-2026-10-01.md):单里程碑范围、接口/schema 边界及验收要求;本地证据已回写主规范。 - [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)、[里程碑](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。 +- [游戏游玩次数计数](./adr/【ADR】游戏游玩次数计数-2026-10-03.md):点「开始游戏」前端上报一次游玩,api-server 纯内存聚合(5s flush、30min 去重、`IP+game` 限流、关停不强制 flush),批量 procedure 自增现有 `game_distribution_game.play_count`,不 bump `updated_at`。 - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) - [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。 - [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。 diff --git a/docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md b/docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md new file mode 100644 index 000000000..4c98a94ec --- /dev/null +++ b/docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md @@ -0,0 +1,143 @@ +# 【ADR】游戏游玩次数计数 + +状态:已接受(2026-10-03) + +## 背景 + +网站游戏分发已经展示「X 次游玩」:目录卡片、详情、作者「我的游戏」与后台「游戏管理」都读 +`game_distribution_game.play_count`,契约里也有 `playCount`。但这条链路只读不写——游戏创建时置 `0`, +之后没有任何自增、reducer 或 procedure;因此所有游戏恒显示 `0` 次游玩。 + +主规范要求「不虚构评分、玩家数、排名和收藏状态」,里程碑与证据矩阵以「新游戏显示 0、目录数据来自真实 +接口」作为无假统计的证据,却从未定义「一次游玩如何累加」。相关现状: + +- **触发信号不存在**:游玩页 `/games/play` 先给封面与「开始游戏」,点击后才挂 + `sandbox="allow-scripts"` 的 iframe;`startGame()` 纯本地状态,不发任何请求。AGC 客户端 12 类埋点白名单 + 不含「开始游玩」,主站路由埋点只有查看统计。 +- **发行网关不能承担计数**:`serve_release_entry` / `serve_release_asset` 只按 `gameId` 服务当前公开版本 + 文件;一次加载会打多个资源请求,且按设计禁带平台 Cookie、拿不到会话身份。 +- **平台没有匿名身份**:没有匿名访客 cookie,web 端没有 device/client id;`x-client-instance-id` 只在登录 + 链路采集。 +- **没有现成的服务端缓冲管线可复用**:AGC 埋点管线是客户端本地 15 分钟一批、服务端同步原子入库且明确 + 不做聚合;真正的「内存缓冲 + 周期 flush + 关停 flush」范式是主站路由埋点 `tracking_outbox`。 +- **部署与限流现状**:api-server 单实例;无 CSRF/Origin 中间件;应用层只有并发背压,按 IP 令牌桶在独立 + 的 `pingora-gateway`;现有缓存惯例是 std `OnceLock`/`LazyLock` + `Mutex`(无 `dashmap`/`moka`)。 + +## 决策 + +### 1. 触发点 = 游玩页点「开始游戏」 + +以用户点击「开始游戏」后的前端上报作为一次游玩。理由:这是产品定义的启动动作,游客可用;`iframe load` +只代表文档加载(规范已明确不能当业务状态),发行网关无法区分会话且会因资源请求重复。点击后即使 iframe +超时/未真正载入也计一次(用户意图)。 + +### 2. 落点 = 复用现有 `play_count` + +`game_distribution_game.play_count` 已经是 `u64`、已进公开/后台 DTO、已进四处 UI。只新增写入路径,不改 +字段语义、不新建计数表,避免双源。计数跟随游戏身份,不随发行版本。 + +### 3. 范围 = 只做累计总次数 + +不做日粒度 / 近 7 天热度、独立玩家数、榜单、推荐。`public_work_play_daily_stat` 属已退役自定义世界口径, +不复活。 + +### 4. api-server 纯内存缓冲 + 周期 flush + +- **纯内存,不落盘**:崩溃、被杀和正常 SIGTERM/滚动重启都允许丢最后一个 flush 窗口;进程退出不做 + force flush,关停路径不为计数等待网络(2026-10-04 修订,见「修订记录」)。 +- **两张表**:增量表 `pending: HashMap`(5 秒级、flush 即清)与 30 分钟去重窗口表 `seen: + HashMap`(30 分钟级、按 TTL 清理)。两者键不同、生命周期差 360 倍,不能合并: + 合并会把 30 分钟窗口状态塞进 5 秒清空的表,或让 flush 需要按 gameId 重新聚合。 +- **限流表**:`rate: HashMap` 固定窗口。 +- **并发**:请求路径只在短锁内做 HashMap 命中 + 自增;**flush 的网络调用移出锁外**。 + +### 5. 写入形状 = 批量 procedure + +一次 flush 发一个 procedure,入参 `Vec<{gameId, delta}>`(按 500 分块),事务内逐条 +`play_count = play_count.saturating_add(delta)`;procedure 内**原子校验**游戏当前为 `published` 且 +`active_version_id` 存在,非公开跳过。**不 bump `updated_at`**:它只表示公开资料变更,且作者自有列表按它 +排序,bump 会让每次游玩重排作者列表。 + +### 6. 失败语义 = 少计优于双计 + +只重试确定未发出的 `Build`;`Timeout` / `ConnectDropped` 无法判断是否已提交,直接丢弃该批并 `warn!` 记录 +丢量。理由是:超时后重试会在"其实已提交"时造成系统性双计,而丢弃只是偶发少计——对一个非交易展示指标, +后者更可接受(perf 优先于 correctness)。 + +一次 flush 按 500 分块;任一分片失败即终止本次 flush 的后续分片,剩余增量直接丢弃(`Build` 只把当前分片 +放回)。连接不通时剩余分片只会重复同样的失败,逐个重试会把 worker 卡在多次连接超时上(2026-10-04 补充)。 + +### 7. 接口 + +`POST /api/game-distribution/games/{game_id}/plays`,公开端点(可选 bearer): + +- 不挂 `require_bearer_auth`;用 `optional_access_token_from_headers` 拿可选 `userId`。 +- **不加 `Idempotency-Key`**(与其它游戏分发写路由惯例不同):高频计数用不上幂等收据,30 分钟去重窗口就是 + 护栏。 +- 非公开 / 下架 / 封禁返回 `404` 且不计数;被限流返回 `429`;成功统一 `200 {recorded: bool}`。 +- 前端 fire-and-forget,**任何失败静默、绝不阻断游玩**;不做发行网关兜底计数。 + +### 8. 身份与去重键 + +登录用 `userId`;匿名用前端 `localStorage` 持久随机 `clientId`(随请求体带上);两者都缺失时回退 +`IP + UA`。30 分钟窗口按 `identity + gameId`。不新造匿名 cookie、不依赖登录后才有的设备指纹。`IP` +取 nginx 覆盖写入的 `X-Real-IP`(无 CDN 时即真实 TCP 对端),不取可伪造的 `X-Forwarded-For` 首段; +限流键与微信支付下单的 `payer_client_ip` 同源(2026-10-04 补充)。 + +### 9. 落位 = 纯持久化 / 读模型 + +procedure 在 `spacetime-module`,facade + mapper 在 `spacetime-client`,缓冲 / worker / 端点在 +`api-server`;`module-game-distribution` 不动(自增不是领域规则,与既有「点赞计数」直接落持久化流程同构)。 +新增 procedure 不改表,schema guard 不触发,但必须 `npm run spacetime:generate` 重生成绑定并同步文档。 + +### 10. 展示一致性 = 接受滞后 + +读路径(目录 / 详情)继续只读 DB,不叠加内存 pending;接受「flush 间隔 + 写库」的 ≤10 秒滞后。 + +## 影响与代价 + +- 计数非实时,最多一个 flush 窗口的滞后;崩溃 / kill 丢最后一个窗口;模糊传输错误少计。 +- 匿名 `clientId` 可被清除 / 伪造,指标定位为展示用次数,仅靠限流兜底。 +- 单实例前提:去重 / 限流窗口不跨实例;将来多实例时各实例自行 flush(加法幂等),窗口不共享。 +- 不 bump `updated_at`,作者自有列表排序、公开修订 CAS 均不受影响。 +- 新增 procedure 只改 ABI(绑定),不改表 schema / `migration.rs`。 + +## 备选方案与取舍 + +1. **发行网关服务端计数**:一次加载多资源请求会重复、禁 Cookie 拿不到会话、无法去重。已否决。 +2. **复用 AGC 客户端埋点管线**:服务端同步入库且明确不做聚合,客户端 15 分钟批次,白名单不含游玩。已否决。 +3. **落盘 / outbox 保可靠**:无持久性需求(崩溃丢窗口已接受),引入磁盘与独立目录要求。已否决。 +4. **单张 map / 不做去重**:会重复计数;去重状态与增量生命周期不同。已否决。 +5. **每个游戏一次 procedure**:N 次 WebSocket 往返。已否决。 +6. **模糊失败重试**:超时已提交时系统性双计。已否决。 +7. **匿名 HttpOnly cookie / 纯 IP 去重**:前者要新造 cookie 与跨端 / 沙箱处理,后者在 NAT 下把多人并成一人。 + 已否决。 + +## 明确不做 + +- 日粒度 / 近 7 天 / 独立玩家数 / 榜单 / 推荐。 +- AGC 客户端界面埋点、外部 API / External OpenAPI 扩展。 +- 服务端网关兜底计数、CSRF token、应用层全局按 IP 限流(沿用既有限流与网关能力)。 +- 幂等收据表、双计补偿、跨实例窗口共享。 + +## 落地与验收 + +- 实施边界:`spacetime-module` 新增批量自增 procedure 与 `SpacetimeType`;`spacetime-client` facade + + mapper;`api-server` 新增计数模块(增量 / 去重 / 限流 / flush worker)、AppState 接线与公开 + 端点;前端 `gameDistributionClient` 增 `recordGamePlay` 并在 `startGame()` 触发。 +- 权威文档同步:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(路由表与游戏分发合同节)、 + `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`(`game_distribution_game` 的 procedure 与 + 写入口径)。 +- 验收判据:新游戏 `0` → 游客点一次 ≤10s 内显示 `1`;同身份 30 分钟内重复点击不增、不同身份各 `+1`; + 超限流 `429` 且不写;下架 `404` 不计数但历史值保留;SIGTERM 关停允许丢最后一个未落库窗口;计数接口任何失败都 + 不影响游玩页;相关 Rust / 前端定向测试与 schema / DDD / 绑定 / DTO parity / 编码 / doc-index 门禁全绿。 + +## 修订记录 + +- 2026-10-03:初版。 +- 2026-10-04:关停不再强制 flush(原「正常 SIGTERM/滚动重启必须在 `finalize_shutdown` 内 force flush」 + 作废)。理由:关停时最后一个窗口丢失概率极低,而强制 flush 需要把 worker 生命周期接进关停顺序并为在途 + 网络写入等待;按"perf 与简单优先"取舍,直接放弃该窗口。同日明确 flush 任一分片失败即丢弃剩余分片。 +- 2026-10-04:客户端 IP 解析改为优先 nginx 覆盖写入的 `X-Real-IP`,`X-Forwarded-For` 只作回退且取最后 + 一段(nginx 用 `$proxy_add_x_forwarded_for` 追加的真实对端),不再信任可伪造的首段——公开上报端点原来 + 用它做匿名身份与限流键,可被伪造 IP 绕过并灌水。无 CDN 前置时 `X-Real-IP` 即真实客户端。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 38a8a29cf..3cb8cf1ef 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -10,6 +10,41 @@ - 验证:`cargo check -p module-runtime`、`cargo test -p module-runtime profile_wallet_config_allows_zero_daily_free_points`、`cargo fmt --check`、根 `npm run typecheck`、`npm run admin-web:typecheck`、共享层与 admin 定向 vitest 22/22、`npm run check:encoding`、`git diff --check` 通过。 +## 2026-10-04 游戏游玩次数修订:关停不强制 flush、flush 失败丢弃剩余分片、客户端 IP 只信 X-Real-IP + +- 变更:ADR `docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md` 修订——原「正常 SIGTERM/滚动重启必须在 `finalize_shutdown` 内 force flush」作废;崩溃、被杀、正常关停都允许丢最后一个未落库窗口,`api-server` 不再注册关停 flush。 +- 新增:一次 flush 按 500 分片,任一分片失败即终止本次 flush,剩余分片直接丢弃(`Build` 只把当前分片放回下一轮),避免连接不通时每个分片各等一次连接超时把 worker 卡住。 +- 理由:关停丢一个窗口概率极低,强制 flush 要为在途网络写入等待、并把 worker 生命周期接进关停顺序;按 perf 与简单优先取舍。 +- 受影响实现:`game_play_counter_worker.rs`(删 `flush_game_play_counter_for_shutdown`、失败即 break)、`main.rs`(`finalize_shutdown` 去掉计数 flush)、`game_play_counter.rs`(`take_pending` 仅测试使用)、`modules/game_distribution.rs`(上报先做内存限流预检再查公开可见性)。 +- 安全修正:`request_context::client_ip_from_headers` 改为优先 nginx 覆盖写入的 `X-Real-IP`,`X-Forwarded-For` 只作回退且取最后一段(nginx 用 `$proxy_add_x_forwarded_for` 追加的真实对端),不再信任可伪造的首段;公开上报端点的匿名身份/限流键与微信支付下单的 `payer_client_ip` 同时受益。无 CDN 前置时 `X-Real-IP` 即真实客户端。 + +## 2026-10-03 游戏游玩次数:api-server 内存去重缓冲 + 批量 procedure 落 play_count + +- 背景:`game_distribution_game.play_count` 早已存在且随公开投影展示,但没有任何写入口;浏览列表、详情或发行网关加载都不能算「游玩」。需要一个不拖慢进入游戏、崩溃时最多少计一个窗口的上报链路。完整决策与备选方案见 ADR `docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md`。 +- 触发与落点:游玩页点击「开始游戏」时网页 fire-and-forget 上报 `POST /api/game-distribution/games/{gameId}/plays`;不建新表,累加既有 `play_count`。 +- 缓冲与写入:`api-server` 纯内存聚合,`GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS`(默认 5s)到点批量调用新 procedure `increment_game_distribution_game_play_counts_and_return`(输入 `Vec<{gameId, delta}>`);事务内只对 `published` 且有有效 `active_version_id` 的记录 `saturating_add`,且不更新 `updated_at`(避免重排作者列表)。读路径不叠加内存值,展示最多滞后一个 flush 间隔。~~正常关停强制 flush~~(2026-10-04 修订:关停不再强制 flush,见上条)。 +- 身份与限流:登录用 `userId`、匿名用网页 `localStorage` 的 `clientId`(不可用时退化为会话内存值)、都拿不到回退 `IP + UA`;`identity + gameId` 30 分钟去重,`IP + gameId` 每分钟 60 次固定窗口限流。非公开/下架/封禁返回 404 且不计数;无效 Bearer 按匿名处理,绝不让计数阻断游玩。 +- 失败语义:只把 `SpacetimeClientError::Build`(未发出)放回重试;`Timeout` / `ConnectDropped` / `Procedure` 直接丢弃并记录丢失量——少计优于双计,本指标不做双计补偿,也不共享跨实例去重窗口。一次 flush 按 500 分片,任一分片失败即终止本次 flush,剩余分片直接丢弃(2026-10-04 补充)。 +- 影响范围:`spacetime-module/game_distribution.rs`(输入类型 + procedure + tx)、`spacetime-client` facade 与生成绑定、`api-server` 新增 `game_play_counter.rs` / `game_play_counter_worker.rs` 及 config/state/main/handler、前端 `gamePlayClientId.ts` / `gameDistributionClient.ts` / `GamePlayPage.tsx`、`.eslintrc.cjs` 白名单。 +- 权威文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 的 `game_distribution_game` 节,以及 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 的「游玩计数(已实现)」节。 +- 验证:`cargo check -p api-server` 与 `cargo test -p api-server game_play_counter`(9 passed)通过;前端定向 vitest(点击上报断言 + clientId 稳定性)与 `eslint --max-warnings 0` 通过;`npm run check:server-rs-ddd`、`npm run check:generated-bindings`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。 + +## 2026-10-03 AGC 画布引用统一走「活跃聊天输入区」注册表(Issue 602) + +- 背景:画布的「引用」按钮与「拖拽批量引用」只派发 window 事件,消费者只有 `App.tsx` 一处,而它插的是绑在 `PlanningChatView` 上的 `chatComposerRef`;2026-09-22 DirectProject 拆分后普通项目走 `directProjectMode` 提前 return,渲染不到策划面 → ref 恒为 `null`,可选链静默吞掉点击(画布上是死按钮)。同一批合并冲突还丢了 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 的监听,批量引用连消费者都没有。 +- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数,并检测到第二个输入区注册时留一条 dev 告警——不改运行时语义;`insertChatReferences` 在空批次 / 无输入区 / 句柄报「这一批没插进去」三种情况返回 `false`)。`DirectProjectComposer` 用 `useImperativeHandle` 暴露 `DirectProjectComposerHandle`(按 ref 转发、由它回答插入是否真的递到输入区),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册**按 ref 转发**的句柄(注册时不读输入区是否就位,因此不依赖父子 effect 顺序)(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`(空批次直接返回:没有要插的东西,不能报成「没有可用的输入区」);返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。 +- 边界:不采用「给 DirectProjectComposer 单独加 ref 出口 + App 按模式分流」的备选(那会把「哪个 ref 此刻是活的」继续留在检测点上)。插入仍经 `ResourceReferenceInput.insertReferences` + `focus()`(光标落在插入之后,连点两次按顺序追加)。真正根治的形态是画布与聊天的共同宿主用 context 下发插入能力;注册表语义与之一致,将来换实现不必动画布。 +- 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts`(新增)、`src/App.tsx`、`src/view/project-development/chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectComposer/DirectProjectComposer.tsx`、`.../planning/PlanningChatView.tsx`、`tests/activeChatComposer.test.ts`(新增,钉注册表合同)、`tests/resourceCanvasChatReferenceDrop.test.tsx`、`tests/appSurface/{project-development,design-agent}.suite.ts`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`pitfalls.md`、本文件。 +- 验证(合并 master 后的最终一轮):`npx vitest run apps/ai-game-creator-shell/tests`(197 passed / 1 skipped 文件,1918 passed / 17 skipped 用例)、`npx vitest run tests/activeChatComposer.test.ts tests/resourceCanvasChatReferenceDrop.test.tsx`(2 files / 12 passed,含注册表合同:空批次、无输入区、句柄报落空、注销身份校验、重复注册告警、乱序注销)、`npx vitest run tests/appSurface.test.ts -t 引用`(4 passed)、`npm run agc:typecheck`(含 `check:tests:types`,exit 0)、`npm run check:encoding`、`git diff --check`、eslint `--max-warnings 0`(改动文件)。反向证伪:去掉注册调用后端到端用例变红;去掉空批次短路 / 重复注册告警后对应新用例各红一处。 + +## 2026-10-04 AGC 渠道更新进程与快捷方式隔离 + +- 背景:Tauri 2.11 的 Windows NSIS 模板通过 `MAINBINARYNAME` 查找并结束进程。dev 与 release 过去共用 `genarrative-ai-game-creator-shell.exe`,更新任一渠道都会结束另一渠道;release 从「陶泥儿 Release」改为「陶泥儿」后,`/UPDATE` 又不会自动重建旧快捷方式。 +- 决策:dev 保留历史主程序文件名以维持升级链;release 使用 `genarrative-ai-game-creator-shell-release.exe`,其它非默认渠道使用带渠道后缀的主程序名。构建期把 `mainBinaryName` 与渠道端点、productName、identifier 同批注入,NSIS 因文件名隔离而只匹配自身渠道进程。 +- 迁移:release Windows 包通过独立安装钩子读取旧 `陶泥儿 Release` 卸载项的安装目录,在原目录安装新包,迁移旧桌面/开始菜单快捷方式并清理旧主程序与孤儿卸载项;dev 继续使用原有旧展示名迁移钩子。 +- 影响范围:AGC 渠道身份脚本、发布构建配置、Tauri 基线配置、Windows NSIS 钩子与渠道发布测试;不改变 OSS 分区、更新端点或客户端数据目录合同。 +- 验证方式:渠道发布脚本定向测试、`check-config.mjs`、真实 NSIS 编译与双渠道安装/更新 smoke;真机安装仍需发布环境执行。 + ## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359) - 背景:AGC 客户端在资源栏目子画布(「UI 交互 / 角色与对象 / 场景与环境 / 音频」)左下角工具栏点「上传」后,提示条给出「已上传 1 个素材」,但当前栏目计数不变(仍「0 项」)、素材出现在「待归类」,用户看到的是"上传成功了但它从这一页消失了"。原因是上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套,而 `upload_local_asset` 原先不接受入口栏目。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index c5aa747b5..c4e72194e 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -119,3 +119,7 @@ Gitea 缓存部署必须区分网络:runner 的 RPC 走 `gitea-runner-fetch-ga AGC Rust 两条 lane、crates、smoke、Backend 和桌面壳测试使用镜像内可信 sccache 对象快照;Native shell release step 显式清空双 wrapper,前端/repository checks 不启用。仅首次人工 bootstrap 时,维护者通过 `scripts/build-gitea-rust-cache.sh` 从远端 master 在限额、无宿主挂载的临时容器中按实际 cwd/profile/目标预热全部测试组,仅编译、不执行测试/应用;后端 workspace 与 spacetime-module 保持独立,AGC 的三个 cwd 入口之间清理预热 target,防止 fresh 判断漏产缓存键。最终镜像只追加 sccache、对象和来源元数据,不包含源码或 target。容量上限 4 GiB,不替代宿主旧镜像/归档清理。PR 只写当前容器层、不回传,不开放 Docker API/发布权限;继续禁用 incremental。`ci-rust-cache.sh` 在快照缺失、工具链不符或 wrapper 探测失败时直接编译,并隔离远程缓存配置和 daemon。分片日志记录编译耗时,收尾输出命中统计;两个 lane 的测试和前置检查不同,耗时差不是严格 A/B。线上存在活跃 CI 时不得重启 runner 或切换标签;全组启用前须刷新完整快照并逐组验证,详见开发运维文档。 `.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane 与 smoke job 必须在编译前通过 `scripts/ci-npm-ci-with-retry.sh` 执行根 `npm ci`:AGC 壳的 `build.rs` 会从 `node_modules` 准备 Claude Agent SDK 与目标平台原生运行时,镜像中的 npm 下载缓存不能替代安装。人工 `scripts/build-gitea-rust-cache.sh` bootstrap 同样在首次编译 AGC 壳前安装 npm 依赖;只有不构建壳的 crates job 继续省略 npm 安装。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 + +## AGC 测试类型门禁 + +AGC 测试(`apps/ai-game-creator-shell/tests/`)与生产 `src/` 同受类型门禁。`apps/ai-game-creator-shell/tsconfig.tests.json` 以 `include: ["src", "tests", "vite.config.ts"]`、`types: ["vite/client", "vitest/globals"]`、`allowImportingTsExtensions`、`allowJs` 全量覆盖:新增测试文件自动纳入,不存在基线、豁免名单或 `@ts-nocheck`。入口 `npm --prefix apps/ai-game-creator-shell run check:tests:types`(`tsc -p tsconfig.tests.json --noEmit`)已并入该 app 的 `typecheck`,因此 `npm run agc:typecheck`、`npm run ai-game-creator-shell:check:web` 与 CI 的 `AI game creator shell web tests` 都会执行。修测试只做类型层改动,不改断言与行为;禁止 `@ts-ignore`、`as any`,仅当 mock 泛型函数确实不可兼容时才允许带中文注释的 `as unknown as`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index beca4a1a9..20a8f87ba 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,23 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-03 AGC 画布「引用」死按钮:window 事件的消费者挂在一个只在另一条链路赋值的 ref 上 + +- **现象**(Issue 602):AGC 资源画布选中一张已登记素材,选中工具条点「引用」(图标 `@`、可见文案与 `title` 都是「引用」)没有任何反应——聊天输入框里不出现 `@素材名` 芯片,也没有任何提示。普通项目(`directProjectMode`)必现,立项策划项目(`planningStartMode`)复现不出来;把素材卡拖到对话栏的批量引用同样没反应。 +- **原因**:画布侧只 `dispatchResourceReferenceInsert` / `dispatchResourceReferenceInsertMany`,而这两个 window 事件的**唯一**消费者是 `App.tsx` 里的 `chatComposerRef.current?.insertReferences(...)`,`chatComposerRef` 又只赋给 `PlanningChatView`;2026-09-22 DirectProject 拆分引入的 `directProjectMode` 提前 return 让普通项目整段跳过后面的策划面渲染 → ref 恒为 `null`,可选链把整次调用静默吞掉。同一批合并冲突还把 2026-09-21 新加的 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 监听整段丢掉,批量引用连消费者都没有。 +- **处理(现行口径)**:`features/project-workspace/activeChatComposer.ts` 保存当前挂载的**那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数 / `insertChatReferences`),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册(两条链路互斥,同一时刻只有一个句柄);`App.tsx` 收敛为一处监听,单条与批量都走 `insertChatReferences`,返回 `false`(空批次 / 此刻没有输入区)时 dev 下 `console.warn`;`chatComposerRef` 只留给策划输入盒自己的 `getDraft` / `clear`。 +- **判据/取证**:`npm run test -- apps/ai-game-creator-shell/tests` 里的 `resourceCanvasChatReferenceDrop.test.tsx`、`appSurface/project-development.suite.ts`(工具条「引用」)、`appSurface/design-agent.suite.ts`(策划链路)都断言**真实输入盒草稿**里出现 `[data-resource-reference-id=""]`,不再是「事件被派发」;临时去掉注册调用后这三条会红,证明用例钉的是真链路。详见 [`【功能说明】AGC聊天素材引用-2026-09-08`](../../【功能说明】AGC聊天素材引用-2026-09-08.md) 文首一节。 +- **边界**:只要还保留「window 事件 + 模块外 ref 约定」这种形态,新增聊天面就必须一起进注册表;更彻底的形态是画布与聊天的共同宿主(`ProjectDevelopmentView`)用 context 下发插入能力,注册表语义与它一致,将来换实现不必动画布。 + +## 2026-10-04 AGC 渠道更新按固定主程序名互相查杀 + +- **现象**:开发版和 release 同时运行时,更新其中一个渠道会把另一个进程一起结束;release 从旧产品名升级后,旧 `陶泥儿 Release.lnk` 仍指向旧安装目录,更新后的 release 没有可用快捷方式。 +- **原因**:Tauri 2.11 的 NSIS `CheckIfAppIsRunning` / `KillProcess` 只按 `MAINBINARYNAME` 匹配。所有渠道都使用 `genarrative-ai-game-creator-shell.exe`,所以插件无法按安装目录区分进程;更新模式还会跳过快捷方式创建,产品名变化后旧图标不会自动迁移。 +- **处理**:`channel-identity.mjs` 新增 `resolveChannelMainBinaryName`:dev 保留旧文件名,release 与其它渠道使用后缀文件名;`createChannelConfig` 同批注入 Tauri `mainBinaryName`。release Windows 包使用独立 `release-installer-hooks.nsh`,从旧 `陶泥儿 Release` 卸载项恢复安装目录,迁移旧快捷方式并删除旧主程序/卸载项;dev 的历史改名钩子保持不变。 +- **不要踩的坑**:只改 `productName` 或 `identifier` 不能阻止 NSIS 互相查杀;只改安装包文件名也不能让 updater 选中正确的进程,必须把 `mainBinaryName` 写入构建期 Tauri 配置,并保证 dev 的历史文件名不变。NSIS 钩子仍只能在 `!macro` 内引用模板常量/插件,且文件必须 UTF-8 with BOM。 +- **判据/验证**:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` 覆盖渠道主程序名、release 钩子与宏位置;`node apps/ai-game-creator-shell/scripts/check-config.mjs` 校验基线;真实 Windows NSIS 编译与 dev/release 同机更新 smoke 仍需发布环境执行。 +- **关联**:`apps/ai-game-creator-shell/scripts/channel-identity.mjs`、`apps/ai-game-creator-shell/scripts/build-release.mjs`、`apps/ai-game-creator-shell/src-tauri/windows/{installer-hooks.nsh,release-installer-hooks.nsh}`、`docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。 + ## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目 - **现象**(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。 @@ -4153,7 +4170,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:target 注册了 SIGTERM 清理逻辑,但 `command.terminate` 只偶尔出现 stopped marker;耗时 300-500ms 的清理经常被提前截断。 - 原因:如果先向 wrapper/bwrap/trampoline/target 共用的外层进程组发送 SIGTERM,wrapper 会先退出,bwrap 的 die-with-parent 随即收走 namespace;名义上的 800ms 宽限并没有真正留给 target。 - 处理:process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 setpgid + PTY slave tcsetpgrp 并恢复信号掩码后才 exec;不能先 spawn 到后台组再由 parent 设前台,否则 target 可能已经因 immediate read 收到 SIGTTIN。Runtime 通过两级私有控制通道请求 trampoline 只向 target group 发 SIGTERM。direct leader 退出后 trampoline 继续检查同组后代,外层 wrapper/bwrap 在最多 800ms 宽限期保持存活,超时才强杀 containment group。reader 发现未换行输出超过上限时必须先原子投影 `output-limit-exceeded` 并唤醒 poll,再异步发送终止控制,不能让高负载下的 supervisor 调度延迟把已越界进程继续暴露为 `running`。 -- 验证:使用直接 bash target 启动同组后台子进程;leader 在输出 READY 后自然退出,仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。 +- 验证:使用直接 bash target 启动同组后台子进程;leader 打印 READY 后用 `wait` 保持存活直到 terminate 真正到达(leader 若自己先退出,客户端调度就被拖进 800ms 宽限窗口,见 2026-10-04「graceful terminate 断言」条),仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs`、`process_session_bridge.rs`、`command_sandbox_trampoline.rs`。 ## 启动记录必须封闭状态组合,child 不能自行猜 durable commit 超时 @@ -6295,3 +6312,41 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 ``,离线只剩"说不出原因"的 HostDropped。 - **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。另有 `agent::claude_code_cli::tests::direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id` 回归覆盖同一回合两次不同回复。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`。 + +## 2026-10-03 AGC 测试不在任何 tsconfig 里时,`satisfies` / `vi.fn<(...)>` 这类类型断言是空写 + +- **现象**:`apps/ai-game-creator-shell/tests/` 从未进过任何 tsconfig,vitest 0.34 用 esbuild 转译不做类型检查,`satisfies TurnFailure`、`vi.fn<(input: X) => void>()` 全部静默通过。把 tests 挂进 `tsconfig.tests.json` 后一次暴露 683 条既有错误(99 个文件、约 47% 的测试文件),单个文件最多 73 条。 +- **根因 1(配置缺口)**:app `tsconfig.json` 的 `include` 只有 `["src","vite.config.ts"]`;根 `typecheck` 只跑 `tsconfig.typecheck-guardrails.json` 的根 `src` 白名单;CI `agc-web` lane 只跑 vitest。三处都不覆盖 app tests。 +- **根因 2(vitest 0.34 口径)**:本仓库固定 `vitest@0.34.6`,其 `fn` 收的是**参数元组**;写成 vitest 1.x 习惯的 `vi.fn<(input: X) => void>()` 会把函数类型当作 `TArgs`,报 `TS2344`(constraint `any[]`)并连带 `TS7053`/`TS2493`。正确口径是 `vi.fn<[X], R>()` 或直接 `vi.fn(impl)`;`.mock.calls[0]` 在 `noUncheckedIndexedAccess` 下需要非空断言。 +- **根因 3(脚本 `.mjs`)**:测试 import `../scripts/*.mjs`,`allowJs: false` 报 `TS7016`;打开 `allowJs` 后 TS 按 JS 默认值推导出过窄签名(回调参数、`spawn`/`spawnSync` 重载、`never[]`、`null`),需要在测试里按运行时真实契约声明局部签名。 +- **根因 4(过期桩)**:62 条 `TS2339/TS2353/TS2739/TS2741` 是测试桩与当前真实类型脱节(字段被删除/改名/新增必填),例如 `setAgentChatProjectPath`、`conversationKey`、`runId`、`supervisor`→`chat`。 +- **现行口径**:见 `development-workflow.md` 的「AGC 测试类型门禁」;tests 必须 0 error,不引入基线或豁免,只改类型层。 +- **环境提示**:Node 24+ 默认启用实验性 Web Storage,全局 `localStorage` 未配置即 `undefined`,会顶掉 vitest 0.34 jsdom 环境里的 Storage,`recentProjectsHook.test.tsx`、`gameDistributionPublish.test.ts` 在 Node 26 上失败(HEAD 即如此)。项目按 `@types/node ^22.14` 面向 Node 22,本机用 fnm 装 v22.23.3 并设为 default(`~/.configure/profile.d/fnm.sh` 在 shell 启动时 `eval "$(fnm env)"`),Node 22 不暴露该全局、jsdom 的 localStorage 正常,仓库无需任何改动。不要用 `--localstorage-file=…` 绕:那只是把 Node 自己的文件型 Storage 顶上来,多个用例文件共享同一份状态。 +- **关联**:`apps/ai-game-creator-shell/tsconfig.tests.json`、`apps/ai-game-creator-shell/package.json`、`apps/ai-game-creator-shell/tests/`。 + +## 2026-10-04 tracing 的 callsite interest 是进程级缓存:并发测试会把 span 调用点缓存成 never,span 看起来"根本没产生" + +- **现象**:`app::tests::http_tracing::unavailable_router_rejection_keeps_generated_context_and_headers` 偶发 `each rejected request should have one HTTP span left: 0 / right: 1`(`server-rs/crates/api-server/src/app.rs`),同一族断言在 `server-rs/crates/platform-llm/src/observability_tests.rs` 偶发 `provider_spans.len() == 1` 失败。同一批代码时而绿时而红,且失败用例都是最早跑的一批。 +- **原因**:`span!`/`info_span!` 宏在调用点缓存 interest 为 `never` 时**静默返回空 span**,连 `new_span` 都不会调用(tracing 0.1.44 `macros.rs` 的 `span!` 分支)。而 `DefaultCallsite` 的 interest **只在调用点首次被命中时算一次**,且计算时用 `DISPATCHERS.rebuilder()`——进程里只注册过一个 dispatcher 时它会退化成 `dispatcher::get_default()`,即**命中线程自己的 dispatcher**(tracing-core 0.1.36 `callsite.rs` 的 `Rebuilder::JustOne`)。libtest 默认并发跑同一二进制里的上千个用例,没有 subscriber 的测试线程一旦抢到 `http.request` / `llm.request` 调用点的首次注册,就会把它永久缓存成 `never`。`with_subscriber` 只在**每次 poll** 设线程本地 dispatcher,纠正不了这个进程级缓存,于是"span 没产生"。 +- **处理(现行口径)**:测试采集不要依赖 `with_subscriber`。改为在整个被测流程期间持有 scoped default(`tracing::subscriber::set_default`,其内部 `Dispatch::new` 会触发 tracing 重建 interest 缓存),并用同一调用点预热探测到连续两轮采集成功为止;测试 subscriber 显式实现 `register_callsite`(目标 span 恒 `always`、其余 `sometimes`),避免自己的重建把其它调用点永久标记成 `never`。**不要**把断言改成"允许 0 个 span",**不要** sleep 赌时序。 +- **验证**:`cargo test --locked -p api-server --bin api-server app::tests::http_tracing`(默认并发与 `--test-threads=1` 各连跑 20 次)、`cargo test -p platform-llm observability_tests`;更接近 CI 并发的是整段 `app::tests::`(91 用例同进程)与 `--skip bgfilter_worker --skip wallet_refund_outbox` 的全量 bin(1133 用例)连跑。 +- **关联**:`server-rs/crates/api-server/src/app.rs`、`server-rs/crates/platform-llm/src/observability_tests.rs`。 + + +## 2026-10-04 AGC 通知计数与 graceful terminate 的断言偶发都来自"跨线程 / 跨用例串台" + +- **现象**:`agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` 偶发 `left: 8 / right: 7`(进度内容变化必须通知一次);`process_session::tests::process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup` 偶发 `left: "exited" / right: "terminated"`;两者都在 `AI game creator shell Rust lane 2/2` 分片里红。 +- **原因 1(通知计数串台)**:测试计数器 `DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT` 在 *2026-10-01 已按线程作用域隔离*(`thread_local! Cell`),但 2026-10-02 退役 `runtime_driver` 把这段接缝搬进 `agent/direct_events.rs` 时**降级回进程级 `static AtomicU64`**。`--test-threads=1` 只串行测试线程,宿主 `tauri::async_runtime` 的后台回合仍在自己的工作线程上广播「运行中的项目」变了,于是断言取到别的回合的广播。 +- **原因 2(terminate 竞速)**:测试命令里 leader 打印 READY 后立刻 `exit 0`,同组后代仍存活,trampoline 从 leader 被回收那一刻开始 `PROCESS_SESSION_TARGET_TERMINATE_GRACE_MS=800ms` 宽限;客户端只要在 leader 退出后 >800ms 才发出 terminate(CI 高负载下要跨 durable record 写盘、registry 注册、线程 spawn),会话已按 `exited` 收口,terminate 只能读到既成事实——不是产品缺陷,是测试赌了客户端调度。 +- **处理(现行口径)**:①测试专用的通知计数必须留在测试线程作用域(`thread_local! Cell`),不要用进程级 Atomic;②graceful terminate 用例的 leader 打印 READY 后要用 `wait` 等后台子进程,让 terminate 必然落在会话仍 running 时(断言、trap、`sleep 0.4`、marker 名字都不改)。 +- **验证**:①修复前把计数器临时改回 Atomic 时同一并行口径 42/50 红;修复后并行 50 次 0 红、`--test-threads=1` 200 次 0 红、CI 现场等价块(145 用例)3 次 0 红;②该用例是 `#[cfg(target_os = "linux")]`,Windows 本机跑不到,用真实 Linux 内核(WSL Alpine)验证命令形状:leader 活到 TERM、同组后代完成 400ms 延迟清理(marker=done,real 0.41s)、清理后组内零残留;CI 侧仍应跑 `node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=4` 复核。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs`、`apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs`、`command_sandbox_trampoline.rs`。 + +## 2026-10-04 SPA 深链的前缀路由(`/pay/`)必须进 allowlist,裸前缀不够 + +- **现象**:只把 `/pay`、`/profile/payment` 加进 Nginx SPA allowlist 让门禁变绿,并不代表真实收银台链接能打开。`payment.rs` 生成的 `checkoutUrl` 是 `/pay/`,三份模板原先只有 `location ~* "^/(?:…|pay|profile|profile/payment|…)/?$"` 这条精确 location,深链落回默认 `location /` 的 `try_files $uri $uri/ =404` → **404**。 +- **原因/代价**:前端 `resolveSelectionStageFromPath` 用 `startsWith('/pay/')` 判定并取最后一个路径段当 token,Nginx / Pingora 侧却只放行裸前缀(2026-10-03 的支付接入 commit 只改了前端路由源)。同一批漂移里还有一条被掩盖的失败:`check:nginx-spa-routes` 在 `npm run lint` 链里先跑,它红的时候看不到后面的 `check:pingora-route-parity` 也红(Pingora `MAIN_SPA_PATHS` 缺 `/pay`、`/profile/payment`)——修一条门禁时要把整条链跑到底,不要只看第一个红。 +- **处理(现行口径)**:前缀路由的真相源是 `src/routing/activeAppPageRoutes.ts` 的 `APP_PREFIX_ROUTE_ENTRIES`。`scripts/check-nginx-spa-routes.mjs` 据此要求三份模板都写锚定前缀 location(`location ~* "^/pay/[^/]+/?$"`,只放行「前缀 + 恰好一个路径段」,裸前缀仍由精确 location 负责,并要求镜像精确 location 的维护闸);`check:pingora-route-parity` 要求 Rust 的 `MAIN_SPA_PREFIX_PATHS` 与 `is_main_spa_prefix_path` 同口径(大小写不敏感、多段与 `/payment/x` 这类同名邻居不收)。 +- **别踩**:不要写成裸前缀正则(`^/pay`)——它会吞掉 `/payment/x`、`/paycheckout/x` 这类同名邻居;也不要把深链塞进精确 allowlist 的 alternatives 里(`pay` 的 alternatives 只匹配 `/pay`)。 +- **判据/取证**:`node --test scripts/check-nginx-spa-routes.test.mjs`(正/反用例,含「写回精确匹配即红」)、`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway -- pay_checkout_deep_link matches_nginx_route_parity_matrix`;线上复验 `curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/` → 200 且正文与 `/` 同一份 `index.html`。 +- **关联**:`scripts/check-nginx-spa-routes.mjs`、`deploy/pingora/nginx-route-parity.matrix.json`、`server-rs/crates/pingora-gateway/src/main.rs`、`server-rs/crates/api-server/src/payment.rs`、`deploy/nginx/genarrative.conf`。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 4634ed447..0674da792 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -58,7 +58,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐 - DirectProject 对话先在完整历史中按回合/原始 item 身份关联,再分页渲染;每个回合只有一个呈现入口。有流按 item `seq` 交替文本和工具,无流采用历史正文;禁止位置猜配或同时展示累计回复与 item 正文。流写入单调归并,收尾等待落盘任务,不按磁盘“最后一段”猜最终回复位置。详见 AGC 实施计划的“DirectProject 回合展示唯一归属”。 - 回合生命周期只由活动 client 回合快照和 Direct 事件恢复;Provider 的历史终态通知不能创建活动 client 回合。消息发送时间保存在历史信封,原始 item 不混入宿主字段;完成后的中间文本和工具默认收进“执行过程”,最终回复及失败提示保持可见。 -- AGC 安装产品名由渠道身份决定:`dev` 显示“陶泥儿开发版”,`release` 复用正式产品名“陶泥儿”,自定义渠道显示“陶泥儿 <渠道显示名>”;Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述,identifier 继续按 `<基线>.<渠道>` 派生,保证渠道数据与运行身份隔离(详见《AGC客户端更新检查与下载》的渠道与安装身份合同)。Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。内部可执行文件名保持稳定。 +- AGC 安装产品名由渠道身份决定:`dev` 显示“陶泥儿开发版”,`release` 复用正式产品名“陶泥儿”,自定义渠道显示“陶泥儿 <渠道显示名>”;Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述,identifier 继续按 `<基线>.<渠道>` 派生,主程序文件名也按渠道隔离(dev 保留历史名,release 使用 `-release` 后缀),保证渠道数据、运行身份与更新进程隔离(详见《AGC客户端更新检查与下载》的渠道与安装身份合同)。Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。 - 新 Web 游戏为 `game/` 下的 npm + Vite + Phaser 4.2.1 工程,使用包导入且允许其它依赖;npm 预览与导出只读取 dist,运行素材需纳入构建。单 HTML → Phaser 迁移固定走 DirectProject:文件落盘后先用受控 `project.bootstrap` 在 `game` 执行无参数 `npm install`,再用支持相对 cwd 的 `project.verify` 构建并确认 `game/dist/index.html`,已有单 HTML/Godot 不通过 JSON Generator 伪装成 npm 工程。 diff --git a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md index a29e55722..e56f73911 100644 --- a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md +++ b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md @@ -63,7 +63,7 @@ npm run check:pingora-release-readiness `check:pingora-gateway-smoke` 会临时启动 mock `api-server`、mock SpacetimeDB、mock Gitea 和 `pingora-gateway`,覆盖精确主站 SPA fallback、大小写与尾部斜杠兼容、同前缀未知路径真实 404、后台静态路由、HTML / 普通静态资源 `no-cache`、Vite 指纹静态资源 immutable 缓存、静态 `ETag` / `Last-Modified` 与 `304` 协商缓存、静态 `HEAD` 响应、静态 Range、静态 access log method/path/status 对账、gzip 最小长度、小响应不压缩、图片资源不压缩、大响应压缩、ACME、TLS 直连、HTTP/2 ALPN、HTTP 到 HTTPS 重定向、内部路由拒绝、shadow probe、API 代理头(`Host` / `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP` / `X-Forwarded-For`)、Gitea Host 整站转发、请求体上限、429 接流保护、上游断连 / 超时 JSON 错误、维护模式、维护模式不拦截 Gitea Host 和 SpacetimeDB WebSocket Upgrade,并复用 `check-pingora-direct-live.mjs` 对临时 HTTPS / HTTP redirect / WSS subscribe 入口做 live smoke。该本地 fixture 会让首页同时引用普通静态资源和 Vite 指纹静态资源,direct live JSON 必须确认指纹资源 GET / HEAD / `Range: bytes=0-0` 以及 access log method/path/status 证据,避免正式直连前只证明普通静态读取。排查失败时可追加 `-- --verbose` 输出网关 stderr / stdout;已确认二进制无需重编时可追加 `-- --skip-build`。 -`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` / `APP_RUNTIME_ROUTES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。 +`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` 与 `APP_PREFIX_ROUTE_ENTRIES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠、前缀路由的「前缀 + 恰好一个路径段」锚定形状(收银台深链 `/pay/` 必须整体回退 `index.html`,只放行裸前缀会让真实链接落到默认 location 变 404)和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。该脚本自带正/反用例(`node --test scripts/check-nginx-spa-routes.test.mjs`,由 `npm run check:nginx-spa-routes` 一起执行),防止有人把前缀路由改回精确匹配。 `check:pingora-route-parity` 会先执行同一 Nginx SPA 路由门禁,再读取 `deploy/pingora/nginx-route-parity.matrix.json`,静态确认生产 / 开发 Nginx 模板、Pingora Rust 路由 allowlist / 单测和本文档都覆盖同一组核心路由,并做**反向覆盖**(模板里的每条 `location` 都必须被矩阵声明)。`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix` 会读取同一份矩阵,逐条断言 `classify_path` 的路由结果、body limit 和接流保护分组。`check:nginx-spa-routes` 与 `check:pingora-route-parity` 已串进 `npm run lint`(因此 `check:repository-ci`、CI 与 pre-push 都会执行),接线本身由 `check:production-ops` 的 guardrail 锁定。 @@ -535,7 +535,8 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清 | `/v1/database/{db}/subscribe`、`/v1/identity*` | 转发到 SpacetimeDB,保留 WebSocket Upgrade 头。 | | `/__genarrative_pingora/healthz` | 仅在携带 `X-Genarrative-Pingora-Probe` 且匹配配置 token 时返回 shadow JSON,否则 404。 | | `/v1/*`、`/generated-*`、`/healthz*`、`/readyz*` | 返回 404,保持生产公网不暴露口径。 | -| 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/profile`、`/project` 失败回退 `/index.html`(集合与前端路由源、Nginx 三份模板逐条一致,由 `npm run check:pingora-route-parity` 与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 比对);匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。`/games/game_<32 位十六进制 id>/…` 是发行网关路由,不在 SPA allowlist 内。 | +| 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/pay`、`/profile`、`/profile/payment`、`/project` 失败回退 `/index.html`(集合与前端路由源、Nginx 三份模板逐条一致,由 `npm run check:pingora-route-parity` 与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 比对);匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。`/games/game_<32 位十六进制 id>/…` 是发行网关路由,不在 SPA allowlist 内。 | +| 主站 SPA 前缀路由 | 收银台深链 `/pay/` 走 `MAIN_SPA_PREFIX_PATHS`:只放行「前缀 + 恰好一个路径段」(大小写不敏感),裸前缀由上面的精确集合负责,多段路径与 `/payment/x` 这类前缀同名邻居都不进 SPA fallback;Nginx 三份模板同口径写成 `location ~* "^/pay/[^/]+/?$"`,由矩阵的 `pay_checkout_spa_fallback` 用例固定。 | | 其它 Web 路径 | 只读取真实静态文件或目录 index,缺失时返回真实 404;`/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 不进入 SPA fallback。 | SPA allowlist 里属于游戏分发入口的深链(游戏目录 / 详情 / 游玩 / 我的 / 发布深链:`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)与 Nginx 三份模板同口径;Pingora 侧由路由对照矩阵的 `games_spa_fallback` 用例与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 逐条断言。根路径 `/` 精确回退 `/index.html`(Nginx 在 `location = /` 里用 `try_files /index.html =404;`,不带 `$uri`),由矩阵的 `web_root_spa` 用例固定。发行网关路径 `/games/game_<32 位十六进制 id>/…` 不走 SPA,见下一节的对照说明。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 59ef49d5b..996793ecb 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -21,6 +21,7 @@ ### 渠道与安装身份合同 - 渠道同时决定**更新端点**与**安装身份**,两者都由构建期写入产物。默认渠道 `dev` 保持基线身份 `productName = 陶泥儿开发版`、`identifier = world.genarrative.ai-game-creator`;`release` 渠道使用正式产品名 `陶泥儿` 并派生 `identifier = world.genarrative.ai-game-creator.release`;其它自定义渠道派生 `productName = 陶泥儿 <渠道显示名>`(如 `beta-2` → `陶泥儿 Beta-2`)与 `identifier = world.genarrative.ai-game-creator.<渠道>`。渠道显示名按连字符分段首字母大写,不改动渠道本身。 +- Windows 主程序文件名也是渠道安装身份的一部分:`dev` 保留 `genarrative-ai-game-creator-shell.exe` 以延续既有升级链,`release` 使用 `genarrative-ai-game-creator-shell-release.exe`,其它渠道使用 `genarrative-ai-game-creator-shell-.exe`。NSIS 更新/卸载按该文件名查找进程,保证更新一个渠道不会结束另一个渠道。 - 默认渠道身份**不可变更**:既有安装目录、卸载项、快捷方式与已发布客户端的升级链都建立在基线身份上。渠道身份由 `apps/ai-game-creator-shell/scripts/channel-identity.mjs` 单点定义,构建入口、macOS 发布入口与配置门禁共同消费;基线 `tauri.conf.json` 必须逐字等于默认渠道身份。 - 安装身份决定的持久与可见事实:Windows 安装目录 `%LOCALAPPDATA%\<产品名>`、卸载项与 `HKCU\Software\genarrative\<产品名>`、WebView2 数据目录 `%LOCALAPPDATA%\`、客户端数据目录 `%APPDATA%\`;macOS `.app` 名、bundle id、DMG 卷名与菜单栏应用名。 - 同机并存:不同渠道的包体可以在同一台设备上同时安装并同时运行,互不覆盖、互不顶掉;同一渠道的新版本仍是原地升级,因为更新端点与安装身份同属一个渠道。 @@ -124,19 +125,20 @@ - 渠道安装身份映射(`` 为 `dev`、`release` 或自定义名称;`` 为渠道显示名): -| 渠道 | productName | identifier | Windows 安装目录 | 客户端数据目录 | -| -------------------- | ------------------ | ---------------------------------------------- | ----------------------------------- | ----------------------------------------------- | -| `dev`(默认) | `陶泥儿` | `world.genarrative.ai-game-creator` | `%LOCALAPPDATA%\陶泥儿` | `%APPDATA%\world.genarrative.ai-game-creator` | -| `release` / 自定义 | `陶泥儿 ` | `world.genarrative.ai-game-creator.` | `%LOCALAPPDATA%\陶泥儿 ` | `%APPDATA%\world.genarrative.ai-game-creator.` | +| 渠道 | productName | 主程序文件名 | identifier | Windows 安装目录 | 客户端数据目录 | +| -------------------- | ------------------ | ------------------------------------------------- | ---------------------------------------------- | ----------------------------------- | ----------------------------------------------- | +| `dev`(默认) | `陶泥儿开发版` | `genarrative-ai-game-creator-shell.exe` | `world.genarrative.ai-game-creator` | `%LOCALAPPDATA%\陶泥儿开发版` | `%APPDATA%\world.genarrative.ai-game-creator` | +| `release` | `陶泥儿` | `genarrative-ai-game-creator-shell-release.exe` | `world.genarrative.ai-game-creator.release` | `%LOCALAPPDATA%\陶泥儿` | `%APPDATA%\world.genarrative.ai-game-creator.release` | +| 自定义渠道 | `陶泥儿 ` | `genarrative-ai-game-creator-shell-.exe` | `world.genarrative.ai-game-creator.` | `%LOCALAPPDATA%\陶泥儿 ` | `%APPDATA%\world.genarrative.ai-game-creator.` | -- 安装身份迁移:`dev` 客户端保持原身份,升级链路连续;`release` 与自定义渠道首次以新身份安装,**不接管也不迁移**任何既有 `dev` 安装、本地项目或登录态,设备上因此可以同时存在两个渠道的客户端,由用户自行决定是否卸载其一。 +- 安装身份迁移:`dev` 客户端保持原 identifier 与主程序文件名,升级链路连续;dev Windows 包继续清理历史展示名 `陶泥儿` / `Genarrative AI Game Creator` 的旧快捷方式。release 从旧展示名 `陶泥儿 Release` 迁移安装目录与快捷方式到当前 `陶泥儿` 身份;release 与自定义渠道不接管也不迁移其它渠道的数据或登录态。 ## 构建与发布 - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 - 发布入口只解析一次目标,优先级为 CLI `--target value` / `--target=value` / `-t value`、`AGC_BUILD_TARGET`、Windows 默认值;重复/空目标与不支持目标失败关闭。版本高水位、构建 feature/渠道端点、bundle 路径、产物后缀、清单平台键及摘要必须消费同一个发布上下文,不能分别回读默认目标。 - 渠道由 `AGC_UPDATE_CHANNEL` 显式指定,默认 dev;Windows 与 macOS 目标均支持 dev、release 和自定义渠道,目标校验独立进行。 -- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier`、updater 端点与窗口标题:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 +- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier`、`mainBinaryName`、updater 端点与窗口标题:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 - 渠道配置走 Tauri 的 JSON Merge Patch 语义:对象递归合并,**数组整体替换**。因此 `app.windows` 必须按基线 `tauri.conf.json` 的完整 client 窗口对象下发、只覆盖 `title`(脚本从基线读取后展开);任何"只写 `{ title }`"的写法都会让 `label` / `decorations` / 尺寸回落成 Tauri 默认值(`label=main`、`decorations=true`、800x600),表现为打包产物重新出现系统标题栏,并按 label 连带失效承载平台 HTTP 权限等 capability。守卫用例:`build-release.test.mjs` 的渠道配置合并用例与 `check-config.mjs` 的 `decorations` 门禁。 - 定时调度分别判断服务端与客户端 scope:dev 小时调度在提交含 AGC 相关路径时发布对应渠道,纯文档或流水线自身的提交仍只跑 Full Build;release 每日调度在服务端相关路径变化时发布正式 Full Build,在 AGC 相关路径变化时发布 release 客户端,并在同一调度内等待、汇总各 lane 结果,失败 lane 下一轮补发。判定失败或勾选强制触发时按"需要发布"处理。 - 更新摘要不再自动生成:发布脚本不读取提交记录生成 `notes`;只有 `AGC_UPDATE_RELEASE_NOTES` 非空时,才把显式手动文案写入渠道清单和旧协议清单的 `releaseNotes`。未设置时清单不携带更新说明,归档文件 `release-notes.txt` 记录“本次没有可用的更新摘要”。 @@ -184,10 +186,11 @@ | 条款 | 验收方式 | 结果 | | --- | --- | --- | -| 渠道身份派生与默认渠道不变 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过;`dev` 逐字等于基线 `陶泥儿开发版` / `world.genarrative.ai-game-creator`,`release` 使用 `陶泥儿` 并保持独立 identifier,`beta-2` 派生带后缀的产品名与独立 identifier,非法渠道失败关闭 | +| 渠道身份派生与默认渠道不变 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过;`dev` 逐字等于基线 `陶泥儿开发版` / `world.genarrative.ai-game-creator` / `genarrative-ai-game-creator-shell`,`release` 使用 `陶泥儿` 并保持独立 identifier 与 `-release` 主程序名,`beta-2` 派生带后缀的产品名、identifier 与主程序名,非法渠道失败关闭 | | 身份与端点同批注入 | 同上的渠道 `--config` 用例 | 通过;`productName` / `identifier` 与 `/-win|mac/latest.json` 来自同一次解析 | | 渠道产物首装包选择 | 同上的渠道 DMG 夹具用例 | 通过;`陶泥儿_<版本>_aarch64.dmg` 仍按 `<版本>_<架构>.dmg` 唯一匹配 | | 基线配置等于默认渠道身份 | `node apps/ai-game-creator-shell/scripts/check-config.mjs` | 通过;基线漂移与非默认渠道身份不隔离都会失败关闭 | +| Windows 更新进程与快捷方式隔离 | `build-release.test.mjs` 渠道配置/NSIS 钩子用例 | 通过;dev/release 主程序文件名不同,release 迁移旧 `陶泥儿 Release` 安装目录与快捷方式 | | 全量发布脚本回归 | `node --test build-release.test.mjs release-oss.test.mjs prepare-macos-codex.test.mjs cargo-features.test.mjs` | 通过(64/64,含 macOS 入口按渠道解析产品名的守卫) | | AGC 自有 AppData 提权 ACL 范围 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- config::private_path_elevation_policy_tests` | 通过(12/12;含基线、`<基线>.release`、`<基线>.beta-2` 与相似前缀 `-backup` 的反向断言) | | 渠道身份进入真实构建产物 | `AGC_UPDATE_CHANNEL=release npm --prefix apps/ai-game-creator-shell run build -- --no-bundle --debug` | 通过;Tauri 接受 `productName=陶泥儿` / `identifier=world.genarrative.ai-game-creator.release` 并完成构建;产物字符串实测 `陶泥儿` × 1、`agc/release-win/latest.json` × 1、`world.genarrative.ai-game-creator.release` × 1、`agc/dev-win/latest.json` × 0 | diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 2591f9d63..6718d6e2c 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -1,9 +1,31 @@ # AGC 聊天素材引用 -更新时间:2026-09-23 +更新时间:2026-10-03 AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,并提供 Codex 风格的 Skill 提及。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;输入 `$` 会按当前 DirectProject 可用 Skill 名称过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。 +## 画布引用落到哪份输入区:活跃聊天输入区注册表(2026-10-03) + +资源画布的「引用」按钮与「拖拽批量引用」都只做一件事:派发 window 自定义事件(`RESOURCE_REFERENCE_INSERT_EVENT` / `RESOURCE_REFERENCE_INSERT_MANY_EVENT`)。**消费者只有 `App.tsx` 一处**,事件本身不携带「插到哪个输入盒」——那由注册表回答: + +- `apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts` 用模块级变量保存**当前挂载的那一个**输入区句柄(`{ insertReferences(refs), focus() }`)。 +- `DirectProjectChatView`(普通项目)与 `PlanningChatView`(立项策划)挂载期间各自注册、卸载注销;两条链路互斥渲染,所以同一时刻只有一个句柄。注册的是**按 ref 转发**的句柄、且不在注册时读输入区是否就位,所以挂载顺序与子组件重挂载都不会让注册表漏挂或指向死句柄。 +- `App.tsx` 的单条与批量两个监听都调 `insertChatReferences(refs)`;返回 `false` 的三种情况(空批次、没有挂载中的输入区、注册表里的句柄报「这一批没插进去」)都不静默,dev 下 `console.warn` 留一行线索;只有真的插进去了才把焦点交给输入区。 +- `chatComposerRef` 只留给策划输入盒自己的提交(`getDraft` / `clear`),不再承担跨面板插入。 + +这是 2026-09-22 DirectProject 拆分后的回归修复(issue #602):当时 `composerRef={chatComposerRef}` 只剩策划面一处,而 `directProjectMode` 的提前 return 让普通项目永远走不到那条赋值,`chatComposerRef.current?.insertReferences(...)` 的可选链把整次调用静默丢掉;同一批合并冲突还把 2026-09-21 新加的批量监听整段丢了,拖拽批量引用连监听者都没有。两条现在都由上面这一处收口。 + +这条链路由以下用例守住(都渲染真实聊天面,断言草稿 DOM 而不是「事件被派发」): + +| 契约 | 用例 | +| --- | --- | +| 工具条「引用」(键盘 + 鼠标两条通路)落进 DirectProject 草稿,光标留在插入之后 | `apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的「工具条里的「引用」把素材 @ 进真实聊天草稿」 | +| 拖拽批量引用整批一次落进草稿、顺序 = 拖动集合顺序、零坐标写入 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` | +| 未登记素材不出「引用」按钮、拖到对话栏只给原因 | `tests/resourceCanvasChatReferenceDrop.test.tsx` 的「未登记素材」用例、`tests/resourceCardReferenceDropModel.test.ts` | +| 注册表自身合同:空批次 / 无输入区 / 句柄报落空 / 注销身份校验 / 重复注册留线索 | `apps/ai-game-creator-shell/tests/activeChatComposer.test.ts` | +| 空批次事件不误报成「没有可用的聊天输入区」 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` 的「空批次引用事件」用例 | +| 策划链路(`PlanningChatView`)不回归 | `apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts` 的「画布派发的「引用」落进策划输入盒草稿」 | + ## 引用来源由宿主注入(2026-09-22) `ResourceReferenceInput` 只接受宿主注入的一组「引用 provider」(`ReferenceProvider`,每种引用一个独立工厂): @@ -60,7 +82,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 拖动期间落点会铺一层虚线框与「松手即可 @ 引用 N 项素材」提示;拖回画布内松手仍是原来的排版语义(写手动坐标),两条语义由落点决定。 - 只认**已登记到 manifest** 的素材,引用身份、来源标记 `resource-card` 与「引用」按钮逐字一致,所以同一素材两处进来是同一枚引用(去重键也一样);一条都引用不了时(素材都未登记)用提示条说明原因。 -- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),草稿只重建一次、插入顺序即拖动集合顺序。 +- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),由 `App.tsx` 的监听一次性交给当前挂载的输入区(见文首「活跃聊天输入区注册表」),草稿只重建一次、插入顺序即拖动集合顺序。2026-09-22 的合并冲突曾把这条监听整段丢掉(事件无消费者),2026-10-03 修复时补回。 ## 附件进入正文(2026-09-22) @@ -83,7 +105,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 支持搜索、类型筛选和多选; - 素材芯片可插入、编辑和删除; - 资源画布支持把资源卡拖到对话栏批量引用(2026-09-21,见上一节); -- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片; +- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片;未登记素材(`manifestAssetId: null`)不渲染这枚按钮,拖到对话栏时落点提示与提示条给出「还没登记为项目资源」的原因; +- 普通项目(DirectProject)与立项策划两条链路都由 `App.tsx` 那一处监听 + 活跃聊天输入区注册表把引用落进当前挂载的输入盒草稿(2026-10-03 修复,见文首); - 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用; - 提交请求携带 canonical user message item; - Rust 按 manifest 二次校验、持久化 canonical item,并生成 Codex wire input; diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index dfeb1d2da..f51a57277 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -485,6 +485,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(截图 `{assetId, objectKey}` 数组);创建游戏时 `api-server` 就复核封面/截图素材存在且属于当前作者(不存在 400、他人素材 403),创建版本时按同一口径再次复核并派生对象键。 发布写入受灰度配置键 `game-distribution:publish` 约束:**灰度默认关闭**,未配置或 `enabled=false` 时写入口(创建游戏/版本、确认包、送审、审核通过激活)返回 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`,`enabled=true` 且白名单/比例/标签命中才放行,读取与安全下架保持可用;同一判据在 `GET /api/runtime/frontend-config` 以 `gameDistributionPublishEnabled` 下发给前端入口,匿名恒为 `false`。只有可见性为 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 上获得匿名读授权。 - 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。 - 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published` 且存在有效 `active_version_id` 的投影。 +- 游玩计数写入:`play_count` 只由批量 procedure `increment_game_distribution_game_play_counts_and_return`(输入 `GameDistributionPlayCountIncrementInput { increments: Vec<{ gameId, delta }> }`)累加。`api-server` 在内存里按 `identity + gameId` 做 30 分钟去重、按 `IP + gameId` 做固定窗口限流后,按 `GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS`(默认 5 秒)批量落库;事务内只对 `published` 且存在有效 `active_version_id` 的游戏 `saturating_add`,非公开静默跳过,且**不更新** `updated_at`。公开 HTTP 入口为 `POST /api/game-distribution/games/{gameId}/plays`,完整行为见玩法链路的「游玩计数(已实现)」。 ### `game_distribution_review` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 9c0acccb8..73b6b7d87 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -748,10 +748,12 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 - 门禁: ```bash -# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由) +# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由与收银台深链前缀路由) npm run check:nginx-spa-routes ``` +线上/预发复验收银台深链时,除了 `npm run check:nginx-spa-routes`,还要用真实请求确认 `/pay/` 返回 SPA 外壳而不是 404(`curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/` 应为 200,正文与 `/` 同一份 `index.html`)。`payment.rs` 生成的 `checkoutUrl` 就是这个路径,只把 `/pay` 加进 allowlist 会让真实链接落到默认 location 变 404。 + 本地想在真实边缘语义下复验时,把 `deploy/nginx/genarrative.conf` 的证书路径与 `/var/log/nginx` 换成临时目录,用 `nginx -c <临时 wrapper>` 起一个临时实例,再用 `curl --resolve <平台域名>:443:127.0.0.1 https://<平台域名>/games//` 验证:入口文档 200 `text/html`、`/games//assets/*` 200、未知 gameId 404,平台 API 与 SPA 路由不受影响。 #### 游戏分发可观测事件 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 33e7ab149..f6263e3aa 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -119,6 +119,7 @@ | --- | --- | --- | | `GET /games` | 游客 | **已实现**:关键词与分类筛选,最多 48 项;仅公开可玩版本 | | `GET /games/{gameId}` | 游客 | **已实现**:当前公开资料与 `currentVersion.entryUrl`;不可见时 404 | +| `POST /games/{gameId}/plays` | 游客/登录 | **已实现**:上报一次「开始游戏」;可选 Bearer,非公开 404、超限 429,成功返回 `{recorded}`;只进 api-server 内存缓冲,失败不影响游玩 | | `GET /game-distribution/releases/{gameId}[/{assetPath}]` | 游客 | **已实现**:根路径等价于 `index.html`;发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;游玩页的入口来自详情投影的 `currentVersion.entryUrl` | | `GET /my/games` | 登录作者 | **已实现**:当前账号游戏、最近版本状态与驳回理由;owner 只从认证主体派生 | | `POST /games` | 登录作者 | **已实现**:幂等创建游戏身份,尚不公开;带 `localProjectId` 时同一作者复用既有 `gameId` | @@ -136,6 +137,14 @@ 领域规则进入 `module-*`,游戏/版本/审核/操作账本和事务进入 `spacetime-module`,访问统一通过 `spacetime-client`,HTTP 与上传编排进入 `api-server`,对象存储副作用复用 `platform-*`,跨端 DTO 同步 Rust `shared-contracts` 与 `packages/shared`。新业务必须使用当前正式表与契约,不得以未挂载源码或非正式私有快照作为公开事实;实际表字段、索引、受信服务身份及迁移清单在持久化里程碑评审时冻结。已有表若确需加字段,只能末尾追加并给明确默认值;删除/改名/重排/改类型必须另行确认迁移计划。 +### 游玩计数(已实现) + +- **触发点**:游玩页点击「开始游戏」时网页上报一次,不做 iframe load、不在发行网关计数、不设停留阈值;点击后即使 iframe 超时也计一次。计数失败静默,绝不阻断进入游戏。 +- **落点**:复用 `game_distribution_game.play_count` 累计次数,随目录、详情、作者「我的游戏」与后台游戏管理投影读取,不另建计数表。 +- **缓冲与延迟**:`api-server` 纯内存聚合(增量表 + 30 分钟去重表 + 限流表),flush 间隔由 `GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS` 配置(默认 5s),经批量 procedure 落库;读路径不叠加内存值,展示最多滞后一个 flush 间隔。崩溃、被杀和正常关停都允许丢最后一个未落库窗口,关停不做强制 flush。 +- **去重与限流**:登录用 `userId`、匿名用网页持久的 `clientId`、都拿不到时回退 `IP + UA`(`IP` 取 nginx 覆盖写入的 `X-Real-IP`,不取可伪造的 `X-Forwarded-For` 首段);`identity + gameId` 30 分钟去重窗口,另按 `IP + gameId` 每分钟 60 次固定窗口限流(超限 429)。公开上报端点先做内存限流预检,超限直接 429、不再查公开可见性;非公开/下架/封禁返回 404 且不计数。 +- **写入语义**:批量 procedure 在事务内只对 `published` 且存在有效公开版本的记录做 `saturating_add`,且**不更新** `updated_at`(避免重排作者列表)。该指标定位为展示用次数,不做交易级幂等、双计补偿或跨实例窗口共享。flush 按 500 分片,任一分片失败即终止本次 flush、剩余分片直接丢弃:`Build`(确定未发出)只把当前分片放回下一轮,其余错误连本批一起丢弃。 + ### 发行路径、沙箱与网络能力 - 发行入口是平台同源路径 `https://<平台域名>/games//`,边缘 nginx 把该前缀原样映射到 `api-server` 发行网关。运行隔离不依赖独立来源,而由 iframe `sandbox="allow-scripts"` 把游戏文档固定在不透明来源:游戏拿不到主站 Cookie、`localStorage`、`IndexedDB`、DOM 与 Service Worker,离开页面即随 iframe 卸载整套游戏代码。 diff --git a/package.json b/package.json index a5001d91c..ca24fab4d 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "check:production-api-deploy": "node scripts/check-production-api-deploy.mjs", "check:pingora-gateway-smoke": "node scripts/check-pingora-gateway-smoke.mjs", "check:nginx-pingora-canary": "node scripts/check-nginx-pingora-canary.mjs", - "check:nginx-spa-routes": "node scripts/check-nginx-spa-routes.mjs", + "check:nginx-spa-routes": "node --test scripts/check-nginx-spa-routes.test.mjs && node scripts/check-nginx-spa-routes.mjs", "check:pingora-route-parity": "node scripts/check-pingora-route-parity.mjs", "check:pingora-canary-live": "node scripts/check-pingora-canary-live.mjs", "check:pingora-canary-live-guard": "node scripts/check-pingora-canary-live-guard.mjs", diff --git a/scripts/check-nginx-spa-routes.mjs b/scripts/check-nginx-spa-routes.mjs index 7a1411443..be4868bcb 100644 --- a/scripts/check-nginx-spa-routes.mjs +++ b/scripts/check-nginx-spa-routes.mjs @@ -1,9 +1,13 @@ #!/usr/bin/env node import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; const APP_PAGE_ROUTES_PATH = 'src/routing/activeAppPageRoutes.ts'; const APP_ROUTES_PATH = 'src/routing/activeAppRoutes.tsx'; +const APP_PREFIX_ROUTE_ENTRIES_PATTERN = + /const APP_PREFIX_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u; const COMPATIBILITY_ROUTES = []; const NGINX_PATHS = [ 'deploy/nginx/genarrative.conf', @@ -86,7 +90,92 @@ function compareRouteSets(actualRoutes, expectedRoutes, label) { } } -function validateNginxRoutes(nginxPath, expectedRoutes) { +/** + * 前缀路由在 Nginx 里的锚定形状:前缀 + 恰好一个路径段 + 一个可省略的尾部斜杠。 + * 裸前缀自身由 SPA allowlist 的精确 location 负责,这里不重复放行,也不放宽到多段路径。 + */ +export function buildPrefixRouteNginxPattern(prefix) { + const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + return `^${escapedPrefix}/[^/]+/?$`; +} + +/** 校验前缀路由的放行形状;返回失败原因列表(空数组代表通过)。 */ +export function collectPrefixRoutePatternFailures(pattern, prefix) { + const failures = []; + const expected = buildPrefixRouteNginxPattern(prefix); + if (pattern !== expected) { + failures.push( + `前缀路由 ${prefix} 的 Nginx 放行形状必须锚定为 ${expected}(前缀 + 恰好一个路径段 + 可省略的尾部斜杠),实际为 ${pattern}。`, + ); + return failures; + } + const matcher = new RegExp(pattern, 'iu'); + for (const sample of [ + `${prefix}/checkout-token`, + `${prefix.toUpperCase()}/CheckOutToken/`, + ]) { + if (!matcher.test(sample)) { + failures.push(`前缀路由形状 ${pattern} 未匹配深链: ${sample}`); + } + } + for (const sample of [ + prefix, + `${prefix}/`, + `${prefix}/two/segments`, + `${prefix}suffix/segment`, + ]) { + if (matcher.test(sample)) { + failures.push(`前缀路由形状 ${pattern} 错误接收非深链路径: ${sample}`); + } + } + return failures; +} + +export function collectExpectedPrefixRoutes() { + const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8'); + const entries = extractSourceBlock( + appPageRoutes, + APP_PREFIX_ROUTE_ENTRIES_PATTERN, + `${APP_PAGE_ROUTES_PATH} APP_PREFIX_ROUTE_ENTRIES`, + ); + const routes = Array.from( + entries.matchAll(/\[\s*'([^']+)'\s*,\s*'([^']+)'\s*\]/gu), + (match) => ({ path: match[1], stage: match[2] }), + ); + const uniqueRoutes = new Map(routes.map((route) => [route.path, route])); + for (const route of uniqueRoutes.values()) { + if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route.path)) { + fail(`前端前缀路由源包含门禁暂不支持的路径格式: ${route.path}`); + } + } + return [...uniqueRoutes.values()].sort((left, right) => + left.path.localeCompare(right.path), + ); +} + +function findRegexLocationBody(block, pattern) { + for (const match of block.matchAll(/location\s+~\*\s+"([^"]+)"\s*\{/gu)) { + if (match[1] !== pattern) { + continue; + } + const openBrace = match.index + match[0].length - 1; + let depth = 0; + for (let index = openBrace; index < block.length; index += 1) { + if (block[index] === '{') { + depth += 1; + } else if (block[index] === '}') { + depth -= 1; + if (depth === 0) { + return block.slice(openBrace + 1, index); + } + } + } + return null; + } + return null; +} + +function validateNginxRoutes(nginxPath, expectedRoutes, prefixRoutes) { const source = readFileSync(nginxPath, 'utf8'); const blockStart = source.indexOf(SPA_BLOCK_START); const blockEnd = source.indexOf(SPA_BLOCK_END); @@ -140,6 +229,46 @@ function validateNginxRoutes(nginxPath, expectedRoutes) { } } + // 前缀路由(带动态段)必须有独立的锚定 location:只放行裸前缀会让真实深链落到默认 + // location 变成 404(例如收银台 `/pay/`)。 + const exactLocationBody = findRegexLocationBody(block, nginxPattern); + const maintenanceGuard = 'if ($genarrative_maintenance) { return 503; }'; + for (const { path: prefix } of prefixRoutes) { + if (!expectedRoutes.includes(prefix)) { + fail( + `${nginxPath} 前缀路由 ${prefix} 必须同时是精确路由:裸前缀自身也要能直达。`, + ); + continue; + } + const expectedPattern = buildPrefixRouteNginxPattern(prefix); + const prefixLocationBody = findRegexLocationBody(block, expectedPattern); + if (prefixLocationBody === null) { + fail( + `${nginxPath} 缺少前缀路由 ${prefix} 的锚定 location(期望 location ~* "${expectedPattern}")。`, + ); + continue; + } + for (const failure of collectPrefixRoutePatternFailures( + expectedPattern, + prefix, + )) { + fail(`${nginxPath} ${failure}`); + } + if (!prefixLocationBody.includes('try_files $uri /index.html =404;')) { + fail( + `${nginxPath} 前缀路由 ${prefix} 的 location 没有精确回退 index.html。`, + ); + } + if ( + exactLocationBody?.includes(maintenanceGuard) && + !prefixLocationBody.includes(maintenanceGuard) + ) { + fail( + `${nginxPath} 前缀路由 ${prefix} 的 location 必须与精确 SPA location 一样先判维护状态。`, + ); + } + } + const defaultLocation = source.slice(blockEnd + SPA_BLOCK_END.length); if (!defaultLocation.includes('try_files $uri $uri/ =404;')) { fail( @@ -218,20 +347,27 @@ function validateMaintenanceInternalBypass() { } export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes(); +export const expectedPrefixRoutes = collectExpectedPrefixRoutes(); -for (const nginxPath of NGINX_PATHS) { - validateNginxRoutes(nginxPath, expectedMainSpaRoutes); -} -validateMaintenanceInternalBypass(); +const isMainModule = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; -if (failures.length > 0) { - console.error('[check:nginx-spa-routes] FAILED'); - for (const failure of failures) { - console.error(`- ${failure}`); +if (isMainModule) { + for (const nginxPath of NGINX_PATHS) { + validateNginxRoutes(nginxPath, expectedMainSpaRoutes, expectedPrefixRoutes); } - process.exit(1); -} + validateMaintenanceInternalBypass(); -console.log( - `[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${NGINX_PATHS.length} Nginx templates)`, -); + if (failures.length > 0) { + console.error('[check:nginx-spa-routes] FAILED'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); + } + + console.log( + `[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${expectedPrefixRoutes.length} prefix routes, ${NGINX_PATHS.length} Nginx templates)`, + ); +} diff --git a/scripts/check-nginx-spa-routes.test.mjs b/scripts/check-nginx-spa-routes.test.mjs new file mode 100644 index 000000000..09d492eb0 --- /dev/null +++ b/scripts/check-nginx-spa-routes.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + buildPrefixRouteNginxPattern, + collectExpectedPrefixRoutes, + collectPrefixRoutePatternFailures, +} from './check-nginx-spa-routes.mjs'; + +test('前缀路由按「前缀 + 恰好一个路径段 + 可省略尾部斜杠」锚定,裸前缀交给精确 location', () => { + const pattern = buildPrefixRouteNginxPattern('/pay'); + assert.equal(pattern, '^/pay/[^/]+/?$'); + assert.deepEqual(collectPrefixRoutePatternFailures(pattern, '/pay'), []); + + const matcher = new RegExp(pattern, 'iu'); + assert.ok(matcher.test('/pay/checkout-token')); + assert.ok(matcher.test('/PAY/CheckOutToken/')); + assert.ok(!matcher.test('/pay')); + assert.ok(!matcher.test('/pay/')); + assert.ok(!matcher.test('/pay/two/segments')); + assert.ok(!matcher.test('/paycheckout/token')); + assert.ok(!matcher.test('/payment/token')); +}); + +test('把前缀路由写回精确匹配(只放行裸前缀)会被门禁拒绝', () => { + const failures = collectPrefixRoutePatternFailures('^/pay/?$', '/pay'); + assert.equal(failures.length, 1); + assert.match(failures[0], /必须锚定为 \^\/pay\/\[\^\/\]\+\/\?\$/u); +}); + +test('过宽的裸前缀形状(会吞掉同名邻居路径)也会被门禁拒绝', () => { + const failures = collectPrefixRoutePatternFailures('^/pay', '/pay'); + assert.equal(failures.length, 1); + assert.match(failures[0], /必须锚定为/u); +}); + +test('前缀路由真相源来自前端路由表且当前包含 /pay', () => { + const prefixRoutes = collectExpectedPrefixRoutes(); + assert.ok( + prefixRoutes.some((route) => route.path === '/pay'), + 'APP_PREFIX_ROUTE_ENTRIES 必须声明 /pay', + ); + for (const route of prefixRoutes) { + assert.match(route.path, /^\//u); + assert.notEqual(route.stage.trim(), ''); + } +}); diff --git a/scripts/check-pingora-route-parity.mjs b/scripts/check-pingora-route-parity.mjs index 1a083564b..49fee68bd 100644 --- a/scripts/check-pingora-route-parity.mjs +++ b/scripts/check-pingora-route-parity.mjs @@ -2,7 +2,10 @@ import { readFileSync } from 'node:fs'; -import { expectedMainSpaRoutes } from './check-nginx-spa-routes.mjs'; +import { + expectedMainSpaRoutes, + expectedPrefixRoutes, +} from './check-nginx-spa-routes.mjs'; const MATRIX_PATH = 'deploy/pingora/nginx-route-parity.matrix.json'; const PRODUCTION_NGINX_PATH = 'deploy/nginx/genarrative.conf'; @@ -289,10 +292,38 @@ function validateRustMainSpaRoutes() { } } +function validateRustMainSpaPrefixPaths() { + const prefixBlock = pingoraGatewaySource.match( + /const MAIN_SPA_PREFIX_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u, + ); + if (!prefixBlock) { + fail('Pingora Rust 缺少 MAIN_SPA_PREFIX_PATHS allowlist。'); + return; + } + const rustPrefixes = Array.from( + prefixBlock[1].matchAll(/"([^"]+)"/gu), + (match) => match[1], + ); + const expected = expectedPrefixRoutes.map((route) => route.path); + const missing = expected.filter((prefix) => !rustPrefixes.includes(prefix)); + const extra = rustPrefixes.filter((prefix) => !expected.includes(prefix)); + if (missing.length > 0) { + fail( + `Pingora MAIN_SPA_PREFIX_PATHS 缺少当前前缀路由: ${missing.join(', ')}`, + ); + } + if (extra.length > 0) { + fail( + `Pingora MAIN_SPA_PREFIX_PATHS 包含非当前前缀路由: ${extra.join(', ')}`, + ); + } +} + validateMatrixShape(); validateRustTestUsesMatrix(); validateNginxLocationsAreCovered(); validateRustMainSpaRoutes(); +validateRustMainSpaPrefixPaths(); if (failures.length > 0) { console.error('[check:pingora-route-parity] FAILED'); diff --git a/scripts/vite-retired-css-plugin.test.ts b/scripts/vite-retired-css-plugin.test.ts index c20f8be19..0fd215089 100644 --- a/scripts/vite-retired-css-plugin.test.ts +++ b/scripts/vite-retired-css-plugin.test.ts @@ -221,6 +221,7 @@ describe('retired creation template module boundary plugin', () => { 'src/persistence/storage.ts', 'src/routing/activeAppRoutes.tsx', 'src/services/apiClient.ts', + 'src/services/gamePlayClientId.ts', ]) { expect(isRetiredFrontendModuleId(workspacePath(path))).toBe(false); } @@ -271,6 +272,7 @@ describe('retired frontend ESLint boundary', () => { 'src/persistence/storage.ts', 'src/routing/activeAppRoutes.tsx', 'src/services/apiClient.ts', + 'src/services/gamePlayClientId.ts', ]) { await expect(eslint.isPathIgnored(path)).resolves.toBe(false); } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 390031ef9..732e0d046 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -510,11 +510,12 @@ mod tests { use tracing::{ Metadata, Subscriber, field::{Field, Visit}, - instrument::WithSubscriber, span::{Attributes, Id, Record}, + subscriber::Interest, }; use super::*; + use crate::app::make_http_request_span; use crate::state::{BackpressureState, HttpRequestPermitPoolKind}; #[derive(Clone, Default)] @@ -534,6 +535,18 @@ mod tests { } impl Subscriber for HttpSpanCapture { + /// tracing 的 callsite interest 是**进程级**缓存,只在调用点首次命中时计算一次。 + /// 这里只为 HTTP span 声明 always:任何以本 subscriber 参与的重建都会让 + /// `http.request` 调用点保持可用;其余调用点返回 sometimes,交给 `enabled` + /// 在真正创建 span 时判断,避免把其它调用点永久标记成 never。 + fn register_callsite(&self, metadata: &Metadata<'_>) -> Interest { + if metadata.is_span() && metadata.name() == "http.request" { + Interest::always() + } else { + Interest::sometimes() + } + } + fn enabled(&self, metadata: &Metadata<'_>) -> bool { metadata.is_span() && metadata.name() == "http.request" } @@ -557,6 +570,41 @@ mod tests { fn exit(&self, _: &Id) {} } + fn http_span_warmup_request() -> Request { + Request::builder() + .uri("/__genarrative_http_span_interest_warmup__") + .body(Body::empty()) + .expect("warmup request should build") + } + + /// 让 `http.request` 调用点在本进程内稳定可用,而不是依赖调用点首次注册时命中线程的 dispatcher。 + /// + /// `DefaultCallsite::register` 只在调用点首次被命中时计算一次 interest,计算时会用 + /// `DISPATCHERS.rebuilder()`;当进程里只有一个 dispatcher 时它会退化成 + /// `dispatcher::get_default()`,也就是**命中线程自己的** dispatcher。并发跑测试时, + /// 没有 subscriber 的线程一旦抢到这次注册,`http.request` 调用点就会被永久缓存成 + /// `never`,之后 `span!` 宏会静默跳过 span 创建,测试便会观察到 0 个 span。 + /// + /// 这里不 sleep 赌时序:每轮都用 `set_default` 在我们的 subscriber 下安装 scoped default + /// (内部 `Dispatch::new` 会触发 tracing 重建 interest 缓存),并直接命中同一个 + /// `http.request` 调用点做探测;连续两轮都探测到 span 才认为缓存已经稳定。 + fn ensure_http_request_span_interest() { + let mut captured_rounds = 0; + for _ in 0..8 { + let probe = HttpSpanCapture::default(); + { + let _guard = tracing::subscriber::set_default(probe.clone()); + let _ = make_http_request_span(&http_span_warmup_request()); + } + let captured = !probe.0.lock().expect("span capture should lock").is_empty(); + captured_rounds = if captured { captured_rounds + 1 } else { 0 }; + if captured_rounds >= 2 { + return; + } + } + panic!("无法在测试 subscriber 下恢复 http.request 调用点的 interest 缓存"); + } + async fn assert_rejection_observed( app: Router, request: Request, @@ -567,11 +615,18 @@ mod tests { let expected_request_id = request.headers().get("x-request-id").cloned(); let path = request.uri().path().to_string(); let capture = HttpSpanCapture::default(); - let response = app - .oneshot(request) - .with_subscriber(capture.clone()) - .await - .expect("rejected request should complete"); + // 先把 http.request 调用点的进程级 interest 缓存校正到当前 subscriber(见上方注释), + // 再在整个请求期间持有 scoped default。注意 `set_default` 是**线程绑定**的:这里成立 + // 是因为本模块用的是默认 `#[tokio::test]`(current-thread 运行时,请求与断言线程一致); + // 一旦改成 multi_thread / spawn 到别的线程,必须换成 `with_current_subscriber()` 或 + // `with_subscriber`,否则请求会在没有 dispatcher 的线程上跑。 + ensure_http_request_span_interest(); + let response = { + let _guard = tracing::subscriber::set_default(capture.clone()); + app.oneshot(request) + .await + .expect("rejected request should complete") + }; assert_eq!(response.status(), expected_status); let request_id = response.headers()["x-request-id"] diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 1b71b2cda..9cb9334c1 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -74,6 +74,8 @@ pub struct AppConfig { pub tracking_outbox_batch_size: usize, pub tracking_outbox_flush_interval: Duration, pub tracking_outbox_max_bytes: u64, + /// 游玩计数内存缓冲的 flush 间隔;写入是批量 procedure,间隔决定展示滞后上限。 + pub game_play_counter_flush_interval: Duration, pub wallet_refund_outbox_enabled: bool, pub wallet_refund_outbox_dir: PathBuf, pub wallet_refund_outbox_batch_size: usize, @@ -374,6 +376,7 @@ impl Default for AppConfig { tracking_outbox_batch_size: 500, tracking_outbox_flush_interval: Duration::from_millis(1_000), tracking_outbox_max_bytes: 256 * 1024 * 1024, + game_play_counter_flush_interval: Duration::from_millis(5_000), wallet_refund_outbox_enabled: true, wallet_refund_outbox_dir: PathBuf::from("server-rs/.data/wallet-refund-outbox"), wallet_refund_outbox_batch_size: 100, @@ -858,6 +861,11 @@ impl AppConfig { { config.tracking_outbox_max_bytes = max_bytes; } + if let Some(flush_interval_ms) = + read_first_positive_u64_env(&["GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS"]) + { + config.game_play_counter_flush_interval = Duration::from_millis(flush_interval_ms); + } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"]) { config.wallet_refund_outbox_enabled = enabled; } @@ -2527,6 +2535,7 @@ mod tests { std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"); + std::env::remove_var("GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"); @@ -2546,6 +2555,7 @@ mod tests { std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE", "250"); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS", "2000"); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES", "1048576"); + std::env::set_var("GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS", "4000"); std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED", "false"); std::env::set_var( "GENARRATIVE_WALLET_REFUND_OUTBOX_DIR", @@ -2577,6 +2587,10 @@ mod tests { std::time::Duration::from_millis(2_000) ); assert_eq!(config.tracking_outbox_max_bytes, 1_048_576); + assert_eq!( + config.game_play_counter_flush_interval, + std::time::Duration::from_millis(4_000) + ); assert!(!config.wallet_refund_outbox_enabled); assert_eq!( config.wallet_refund_outbox_dir, @@ -2601,6 +2615,7 @@ mod tests { std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"); + std::env::remove_var("GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"); std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"); diff --git a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs index 02f47b42f..1983ff877 100644 --- a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs +++ b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs @@ -30,7 +30,10 @@ fn prepare( #[test] fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() { assert_eq!(TOOLS.len(), 15); - assert_eq!(MCP_OPERATIONS.len(), 30); + // legacy 工具集合直接来自内置 OpenAPI(排除 5 条 x-mcp-excluded 元数据/入口操作)。 + // 支付收银台新增 createExternalPaymentOrder / getExternalPaymentOrder 后, + // 可调用操作由 30 增至 32:语义工具只做增量,不得替换 legacy 工具。 + assert_eq!(MCP_OPERATIONS.len(), 32); let legacy = MCP_OPERATIONS .iter() .map(mcp_operation_tool) @@ -52,7 +55,9 @@ fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() { .is_some_and(|text| !text.is_empty()) ); } - assert_eq!(names.len(), 45); + // 37 条 OpenAPI 操作里 5 条元数据/入口操作被 x-mcp-excluded 排除,剩下 32 条 legacy 工具 + // 加 15 条语义工具共 47 条;上面的插入断言已保证两组名字互不覆盖,这里固定总数防止漏注册。 + assert_eq!(names.len(), 47); } #[test] diff --git a/server-rs/crates/api-server/src/game_play_counter.rs b/server-rs/crates/api-server/src/game_play_counter.rs new file mode 100644 index 000000000..7fc5b8c46 --- /dev/null +++ b/server-rs/crates/api-server/src/game_play_counter.rs @@ -0,0 +1,473 @@ +//! 游戏游玩次数的进程内聚合缓冲。 +//! +//! 只在内存里累计「开始游戏」上报:按 `identity + game_id` 做去重窗口、按 `IP + game_id` 做固定 +//! 窗口限流,并按 flush 间隔或待落库游戏数上限决定何时把增量交给写库方。 +//! +//! 网络写入不在本模块内:`take_pending_if_due` / `requeue` 让调用方在锁外发起 procedure,锁只覆盖 +//! HashMap 操作。时间点全部由调用方传入 `now`,因此本模块不依赖运行时,可直接单测。 + +use std::{ + collections::HashMap, + sync::{Mutex, MutexGuard}, + time::{Duration, Instant}, +}; + +use tracing::warn; + +/// 默认 flush 间隔;与 `tracking_outbox` 的秒级节奏一致,够短以保证展示及时。 +const DEFAULT_FLUSH_INTERVAL: Duration = Duration::from_secs(5); +/// 同一 `identity + game_id` 的去重窗口。 +const DEFAULT_DEDUP_WINDOW: Duration = Duration::from_secs(30 * 60); +/// `IP + game_id` 固定窗口长度。 +const DEFAULT_RATE_WINDOW: Duration = Duration::from_secs(60); +/// 单个 `IP + game_id` 在每个固定窗口内允许的上报次数。 +const DEFAULT_RATE_LIMIT: u32 = 60; +/// 待落库游戏数上限;达到后下一次检查立即 flush,而不是等满一整个间隔。 +const DEFAULT_MAX_PENDING_GAMES: usize = 4096; + +/// 一次上报携带的最小信息。`identity` 由调用方决定:登录用户是 userId,匿名是 clientId, +/// 都拿不到时才回退 `IP + UA`。 +#[derive(Clone, Copy, Debug)] +pub struct GamePlayReport<'a> { + pub game_id: &'a str, + pub identity: &'a str, + pub client_ip: &'a str, +} + +/// 单次上报的判定结果。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GamePlayOutcome { + /// 已计入待落库增量。 + Counted, + /// 落在去重窗口内,未计入。 + Deduped, + /// 超过 `IP + game_id` 固定窗口上限,未计入。 + RateLimited, +} + +/// 待落库的增量;同一 `game_id` 在一个批次内只出现一次。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GamePlayCountDelta { + pub game_id: String, + pub delta: u64, +} + +/// 计数器参数。默认值覆盖决策口径,`flush_interval` 由 `AppConfig` 覆盖。 +#[derive(Clone, Copy, Debug)] +pub struct GamePlayCounterSettings { + pub flush_interval: Duration, + pub dedup_window: Duration, + pub rate_window: Duration, + pub rate_limit: u32, + pub max_pending_games: usize, +} + +impl Default for GamePlayCounterSettings { + fn default() -> Self { + Self { + flush_interval: DEFAULT_FLUSH_INTERVAL, + dedup_window: DEFAULT_DEDUP_WINDOW, + rate_window: DEFAULT_RATE_WINDOW, + rate_limit: DEFAULT_RATE_LIMIT, + max_pending_games: DEFAULT_MAX_PENDING_GAMES, + } + } +} + +/// 游玩计数内存缓冲。 +pub struct GamePlayCounter { + settings: GamePlayCounterSettings, + inner: Mutex, +} + +struct GamePlayCounterState { + /// `game_id -> 待落库增量`;只包含通过校验的上报,键空间由公开游戏目录界定。 + pending: HashMap, + /// `(identity, game_id) -> 最近一次计数时间`。 + seen: HashMap<(String, String), Instant>, + /// `(IP, game_id) -> 当前固定窗口`。 + rate: HashMap<(String, String), RateWindow>, + /// 最近一次真正取走增量的时间,用于判断是否到达 flush 间隔。 + last_flush_at: Instant, +} + +struct RateWindow { + started_at: Instant, + count: u32, +} + +impl GamePlayCounter { + pub fn new(settings: GamePlayCounterSettings, now: Instant) -> Self { + Self { + settings, + inner: Mutex::new(GamePlayCounterState { + pending: HashMap::new(), + seen: HashMap::new(), + rate: HashMap::new(), + last_flush_at: now, + }), + } + } + + pub fn flush_interval(&self) -> Duration { + self.settings.flush_interval + } + + /// 处理一次上报:先去重,再限流,最后累加增量。 + /// + /// 被去重命中的上报不消耗限流额度;限流只挡同一 IP 对同一游戏的超额上报。 + pub fn record(&self, report: GamePlayReport<'_>, now: Instant) -> GamePlayOutcome { + // 键用元组而不是拼接字符串:clientId 来自 JSON,可能包含任意字节(含 U+001F), + // 拼接会产生本不存在的键碰撞。 + let dedup_key = (report.identity.to_string(), report.game_id.to_string()); + let rate_key = (report.client_ip.to_string(), report.game_id.to_string()); + let mut state = self.lock(); + + if let Some(seen_at) = state.seen.get(&dedup_key) + && now.saturating_duration_since(*seen_at) < self.settings.dedup_window + { + return GamePlayOutcome::Deduped; + } + + let rate = state.rate.entry(rate_key).or_insert_with(|| RateWindow { + started_at: now, + count: 0, + }); + if now.saturating_duration_since(rate.started_at) >= self.settings.rate_window { + rate.started_at = now; + rate.count = 0; + } + if rate.count >= self.settings.rate_limit { + return GamePlayOutcome::RateLimited; + } + rate.count = rate.count.saturating_add(1); + + state.seen.insert(dedup_key, now); + let pending = state.pending.entry(report.game_id.to_string()).or_insert(0); + *pending = pending.saturating_add(1); + GamePlayOutcome::Counted + } + + /// 限流预检:只读,不消耗额度、不改任何状态。 + /// + /// 公开上报接口在昂贵的公开可见性查询之前先用它挡掉明显超限的请求;真正计数时 + /// `record` 仍会重新判定一次,所以这里只用于省一次远端查询,不承担正确性。 + pub fn is_rate_limited(&self, game_id: &str, client_ip: &str, now: Instant) -> bool { + let key = (client_ip.to_string(), game_id.to_string()); + let state = self.lock(); + state.rate.get(&key).is_some_and(|window| { + now.saturating_duration_since(window.started_at) < self.settings.rate_window + && window.count >= self.settings.rate_limit + }) + } + + /// 到达 flush 间隔或待落库游戏数达到上限时取走全部增量;否则返回 `None`。 + pub fn take_pending_if_due(&self, now: Instant) -> Option> { + let mut state = self.lock(); + if state.pending.is_empty() { + return None; + } + let interval_elapsed = + now.saturating_duration_since(state.last_flush_at) >= self.settings.flush_interval; + let at_capacity = state.pending.len() >= self.settings.max_pending_games; + if !interval_elapsed && !at_capacity { + return None; + } + state.last_flush_at = now; + let deltas = drain_pending(&mut state.pending); + drop(state); + Some(sort_deltas(deltas)) + } + + /// 无条件取走全部增量;仅供测试使用,生产路径走 `take_pending_if_due`。 + #[cfg(test)] + pub fn take_pending(&self) -> Vec { + let mut state = self.lock(); + let deltas = drain_pending(&mut state.pending); + drop(state); + sort_deltas(deltas) + } + + /// 写库失败时把增量放回,等待下一次 flush。 + pub fn requeue(&self, deltas: &[GamePlayCountDelta]) { + let mut state = self.lock(); + for delta in deltas { + let pending = state.pending.entry(delta.game_id.clone()).or_insert(0); + *pending = pending.saturating_add(delta.delta); + } + } + + /// 清掉过期的去重与限流条目,避免 map 无界增长。 + pub fn prune_expired(&self, now: Instant) { + let mut state = self.lock(); + let dedup_window = self.settings.dedup_window; + let rate_window = self.settings.rate_window; + state + .seen + .retain(|_, seen_at| now.saturating_duration_since(*seen_at) < dedup_window); + state + .rate + .retain(|_, window| now.saturating_duration_since(window.started_at) < rate_window); + } + + #[cfg(test)] + pub fn pending_game_count(&self) -> usize { + self.lock().pending.len() + } + + #[cfg(test)] + pub fn pending_total(&self) -> u64 { + self.lock().pending.values().copied().sum() + } + + fn lock(&self) -> MutexGuard<'_, GamePlayCounterState> { + // 计数是尽力而为的展示指标:锁中毒时继续用内部状态,不让一次 panic 永久关闭计数。 + // 但中毒意味着上一次 panic 可能留下部分更新的状态,必须留下可关联的日志。 + self.inner.lock().unwrap_or_else(|poisoned| { + warn!("游戏游玩计数锁已中毒,继续使用内部状态"); + poisoned.into_inner() + }) + } +} + +fn drain_pending(pending: &mut HashMap) -> Vec { + pending + .drain() + .filter_map(|(game_id, delta)| (delta > 0).then_some(GamePlayCountDelta { game_id, delta })) + .collect() +} + +/// 稳定批次顺序,便于测试与日志比对;调用方已释放计数锁,排序不阻塞并发 `record`。 +fn sort_deltas(mut deltas: Vec) -> Vec { + deltas.sort_by(|left, right| left.game_id.cmp(&right.game_id)); + deltas +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings() -> GamePlayCounterSettings { + GamePlayCounterSettings { + flush_interval: Duration::from_secs(5), + dedup_window: Duration::from_secs(30 * 60), + rate_window: Duration::from_secs(60), + rate_limit: 3, + max_pending_games: 4, + } + } + + fn report<'a>(game_id: &'a str, identity: &'a str, client_ip: &'a str) -> GamePlayReport<'a> { + GamePlayReport { + game_id, + identity, + client_ip, + } + } + + #[test] + fn repeated_report_within_dedup_window_is_deduped() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + assert_eq!( + counter.record(report("g1", "u1", "1.1.1.1"), start), + GamePlayOutcome::Counted + ); + assert_eq!( + counter.record( + report("g1", "u1", "1.1.1.1"), + start + Duration::from_secs(60) + ), + GamePlayOutcome::Deduped + ); + assert_eq!(counter.pending_total(), 1); + } + + #[test] + fn dedup_expires_after_window() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + counter.record(report("g1", "u1", "1.1.1.1"), start); + assert_eq!( + counter.record( + report("g1", "u1", "1.1.1.1"), + start + Duration::from_secs(30 * 60) + ), + GamePlayOutcome::Counted + ); + assert_eq!(counter.pending_total(), 2); + } + + #[test] + fn different_identities_count_separately() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + counter.record(report("g1", "u1", "1.1.1.1"), start); + counter.record(report("g1", "u2", "1.1.1.1"), start); + assert_eq!(counter.pending_total(), 2); + assert_eq!(counter.pending_game_count(), 1); + } + + #[test] + fn rate_limit_blocks_excess_and_resets_after_window() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + for index in 0..3 { + assert_eq!( + counter.record(report("g1", &format!("u{index}"), "1.1.1.1"), start), + GamePlayOutcome::Counted + ); + } + assert_eq!( + counter.record(report("g1", "u9", "1.1.1.1"), start), + GamePlayOutcome::RateLimited + ); + + assert_eq!( + counter.record( + report("g1", "u9", "1.1.1.1"), + start + Duration::from_secs(60) + ), + GamePlayOutcome::Counted + ); + } + + #[test] + fn rate_limit_precheck_is_read_only_and_window_scoped() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + assert!(!counter.is_rate_limited("g1", "1.1.1.1", start)); + for index in 0..3 { + counter.record(report("g1", &format!("u{index}"), "1.1.1.1"), start); + } + + // 预检只读:连续调用不消耗额度,也不改变判定。 + assert!(counter.is_rate_limited("g1", "1.1.1.1", start)); + assert!(counter.is_rate_limited("g1", "1.1.1.1", start)); + // 另一个 IP、另一个游戏都不受影响。 + assert!(!counter.is_rate_limited("g1", "2.2.2.2", start)); + assert!(!counter.is_rate_limited("g2", "1.1.1.1", start)); + // 固定窗口结束后恢复。 + assert!(!counter.is_rate_limited("g1", "1.1.1.1", start + Duration::from_secs(60))); + } + + #[test] + fn take_pending_if_due_waits_for_interval_then_drains() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + counter.record(report("g1", "u1", "1.1.1.1"), start); + assert!( + counter + .take_pending_if_due(start + Duration::from_secs(4)) + .is_none() + ); + + let deltas = counter + .take_pending_if_due(start + Duration::from_secs(5)) + .expect("到达间隔后应当取走增量"); + assert_eq!( + deltas, + vec![GamePlayCountDelta { + game_id: "g1".to_string(), + delta: 1, + }] + ); + assert_eq!(counter.pending_total(), 0); + } + + #[test] + fn capacity_triggers_early_flush() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + for index in 0..4u64 { + counter.record( + report(&format!("g{index}"), "u1", "1.1.1.1"), + start + Duration::from_millis(index * 10), + ); + } + let deltas = counter + .take_pending_if_due(start + Duration::from_secs(1)) + .expect("达到待落库游戏数上限应当立即 flush"); + assert_eq!(deltas.len(), 4); + } + + #[test] + fn aggregates_per_game_and_sorts() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + counter.record(report("g2", "u1", "1.1.1.1"), start); + counter.record(report("g1", "u2", "1.1.1.1"), start); + counter.record(report("g2", "u2", "1.1.1.1"), start); + + let deltas = counter.take_pending(); + assert_eq!( + deltas, + vec![ + GamePlayCountDelta { + game_id: "g1".to_string(), + delta: 1, + }, + GamePlayCountDelta { + game_id: "g2".to_string(), + delta: 2, + }, + ] + ); + } + + #[test] + fn requeue_restores_deltas() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + counter.record(report("g1", "u1", "1.1.1.1"), start); + let deltas = counter.take_pending(); + assert_eq!(counter.pending_total(), 0); + + counter.requeue(&deltas); + assert_eq!(counter.pending_total(), 1); + } + + #[test] + fn prune_expired_drops_old_dedup_and_rate_entries() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + counter.record(report("g1", "u1", "1.1.1.1"), start); + counter.prune_expired(start + Duration::from_secs(30 * 60 + 1)); + + // 去重条目过期后同一身份还能重新计数。 + assert_eq!( + counter.record( + report("g1", "u1", "1.1.1.1"), + start + Duration::from_secs(30 * 60 + 2) + ), + GamePlayOutcome::Counted + ); + } + + #[test] + fn separator_like_bytes_in_key_parts_do_not_collide() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + // 旧实现用 U+001F 拼接 identity/game_id,下面两个不同元组会被拼成同一个键。 + assert_eq!( + counter.record(report("b\u{1f}c", "a", "1.1.1.1"), start), + GamePlayOutcome::Counted + ); + assert_eq!( + counter.record(report("c", "a\u{1f}b", "1.1.1.1"), start), + GamePlayOutcome::Counted + ); + assert_eq!(counter.pending_total(), 2); + } +} diff --git a/server-rs/crates/api-server/src/game_play_counter_worker.rs b/server-rs/crates/api-server/src/game_play_counter_worker.rs new file mode 100644 index 000000000..44d4148c8 --- /dev/null +++ b/server-rs/crates/api-server/src/game_play_counter_worker.rs @@ -0,0 +1,112 @@ +//! 游玩计数的 flush worker:按间隔把内存增量批量写进 SpacetimeDB。 +//! +//! 失败语义与 ADR 一致:连接还没建起来(`Build`)属于确定未发出,交给计数器放回下一轮重试; +//! 其余错误(`Timeout` / `ConnectDropped` / `Procedure`)无法判断是否已提交,直接丢弃该批并 +//! 记录丢失量,避免系统性双计。 +//! +//! 任一分片失败都会终止本次 flush 的后续分片:连接不通时剩余分片只会重复同样的失败,逐个重试 +//! 会把 worker 卡在多次连接超时上;剩余增量按“直接丢弃”处理,尽快回到 tick。 +//! +//! 进程关停不做强制 flush:内存里剩下的增量随进程结束丢弃,关停路径不为它等待网络。 + +use std::time::{Duration, Instant}; + +use spacetime_client::{GameDistributionPlayCountIncrementRecordInput, SpacetimeClientError}; +use tokio::time::sleep; +use tracing::{info, warn}; + +use crate::{game_play_counter::GamePlayCountDelta, state::AppState}; + +/// 一次 procedure 最多携带多少条游戏增量;只影响帧大小,不影响累计结果。 +pub(crate) const GAME_PLAY_COUNT_FLUSH_BATCH_SIZE: usize = 500; + +/// worker 检查节拍:比默认 flush 间隔细,以便待落库游戏数达到上限时提前 flush。 +const GAME_PLAY_COUNTER_TICK: Duration = Duration::from_secs(1); + +/// 启动 flush worker;只在 HTTP 角色进程注册一次。 +pub(crate) fn spawn_game_play_counter_worker(state: AppState) { + let tick = GAME_PLAY_COUNTER_TICK.min(state.game_play_counter().flush_interval()); + tokio::spawn(async move { + loop { + sleep(tick).await; + let now = Instant::now(); + let counter = state.game_play_counter(); + counter.prune_expired(now); + if let Some(deltas) = counter.take_pending_if_due(now) { + flush_deltas(&state, deltas).await; + } + } + }); +} + +/// 把一批增量写库。 +/// +/// `Build`(确定未发出)放回等待下一轮;其余错误无法判断是否已提交,直接丢弃并记丢失量。 +async fn flush_deltas(state: &AppState, deltas: Vec) { + let mut accepted = 0usize; + let mut dropped = 0u64; + let mut start = 0usize; + while start < deltas.len() { + let end = (start + GAME_PLAY_COUNT_FLUSH_BATCH_SIZE).min(deltas.len()); + let chunk = &deltas[start..end]; + match write_batch(state, chunk).await { + Ok(()) => { + accepted += chunk.len(); + start = end; + } + // 连接没建起来(确定未发出):本批放回,但后面的分片会重复同样的失败, + // 直接丢弃剩余,避免每个分片各等一次连接超时把 worker 卡住。 + Err(SpacetimeClientError::Build(message)) => { + state.game_play_counter().requeue(chunk); + let lost = total_delta(&deltas[end..]); + dropped = dropped.saturating_add(lost); + warn!( + error = %message, + games = chunk.len(), + lost, + "游戏游玩计数写入连接未建立:本批放回等待下一轮,剩余增量直接丢弃" + ); + break; + } + // 结果未知:连本批一起丢弃剩余,避免双计,也不再把 worker 卡在逐个重连上。 + Err(error) => { + let lost = total_delta(&deltas[start..]); + dropped = dropped.saturating_add(lost); + warn!( + error = %error, + games = deltas.len() - start, + lost, + "游戏游玩计数写入失败,剩余增量直接丢弃" + ); + break; + } + } + } + if accepted > 0 { + info!(games = accepted, "游戏游玩计数已批量落库"); + } + if dropped > 0 { + warn!(dropped, "游戏游玩计数存在丢弃量"); + } +} + +fn total_delta(deltas: &[GamePlayCountDelta]) -> u64 { + deltas.iter().map(|delta| delta.delta).sum() +} + +async fn write_batch( + state: &AppState, + deltas: &[GamePlayCountDelta], +) -> Result<(), SpacetimeClientError> { + let increments = deltas + .iter() + .map(|delta| GameDistributionPlayCountIncrementRecordInput { + game_id: delta.game_id.clone(), + delta: delta.delta, + }) + .collect(); + state + .spacetime_client() + .increment_game_distribution_game_play_counts(increments) + .await +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 477b7df9c..06dc057a1 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -48,6 +48,8 @@ mod external_generation_worker_controller; mod external_mcp; mod external_skill_api; mod frontend_runtime_config; +mod game_play_counter; +mod game_play_counter_worker; mod generated_image_assets; mod health; mod http_error; @@ -700,6 +702,7 @@ fn spawn_http_app_state_background_workers(state: &AppState, process_role: Proce crate::payment_webhook::PaymentWebhookWorker::new(state.spacetime_client().clone()) .spawn_worker(); crate::error_reports::spawn_cleanup_worker(state.clone()); + crate::game_play_counter_worker::spawn_game_play_counter_worker(state.clone()); if should_start_profile_recharge_expiration_listener(process_role) { spawn_profile_recharge_expiration_listener(state.clone()); spawn_profile_recharge_refund_reconciliation_worker(state.clone()); diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index a892127b7..184afb343 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -1,7 +1,7 @@ use std::{ collections::{BTreeMap, HashMap, VecDeque}, sync::{Arc, Mutex, OnceLock}, - time::{SystemTime, UNIX_EPOCH}, + time::{Instant, SystemTime, UNIX_EPOCH}, }; use axum::{ @@ -62,10 +62,11 @@ use uuid::Uuid; use crate::{ admin::{AuthenticatedAdmin, require_admin_auth}, api_response::json_success_body, - auth::{AuthenticatedAccessToken, require_bearer_auth}, + auth::{AuthenticatedAccessToken, optional_access_token_from_headers, require_bearer_auth}, + game_play_counter::{GamePlayOutcome, GamePlayReport}, http_error::AppError, platform_errors::{map_llm_error, map_oss_error}, - request_context::RequestContext, + request_context::{RequestContext, client_ip_from_headers}, state::AppState, }; @@ -344,6 +345,10 @@ pub fn router(state: AppState) -> Router { let public_games = Router::new() .route("/api/game-distribution/games", get(list_games)) .route("/api/game-distribution/games/{game_id}", get(get_game)) + .route( + "/api/game-distribution/games/{game_id}/plays", + post(record_game_play), + ) .route_layer(middleware::from_fn(add_no_store_response_headers)); Router::new() @@ -931,6 +936,112 @@ async fn get_game( Ok(json_success_body(Some(&ctx), public_game_payload(game))) } +/// 一次游玩上报的请求体;只有匿名身份需要 `clientId`,登录身份由 bearer 决定。 +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RecordGamePlayRequest { + #[serde(default)] + client_id: Option, +} + +/// 记录一次「开始游戏」。 +/// +/// 公开端点:登录用户按 `userId` 去重,匿名按 `clientId`(缺失时回退 `IP + UA`)去重; +/// 命中 30 分钟去重窗口或超过 `IP + game` 限流时不增加计数。计数只进内存缓冲, +/// 立即返回 `recorded`,任何失败都不影响游玩本身。 +async fn record_game_play( + State(state): State, + Extension(ctx): Extension, + Path(game_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Result, AppError> { + let game_id = game_id.trim().to_string(); + if game_id.is_empty() { + return Err(AppError::from_status(StatusCode::NOT_FOUND)); + } + + let client_ip = client_ip_from_headers(&headers); + // 先在内存里挡掉明显超限的请求,避免它们也去打一次 SpacetimeDB;真正计数时 record 会再判一次。 + if state + .game_play_counter() + .is_rate_limited(&game_id, &client_ip, Instant::now()) + { + return Err(AppError::from_status(StatusCode::TOO_MANY_REQUESTS)); + } + + // 非公开 / 已下架 / 已暂停的游戏不计数,按不存在返回。 + let is_public = state + .spacetime_client() + .get_public_game_distribution_game(game_id.clone()) + .await + .map_err(map_spacetime_error)? + .is_some(); + if !is_public { + return Err(AppError::from_status(StatusCode::NOT_FOUND)); + } + + let user_agent = user_agent_tag(&headers); + let authenticated = optional_access_token_from_headers( + &state, + format!("/api/game-distribution/games/{game_id}/plays"), + headers, + ctx.request_id().to_string(), + ) + .await + .unwrap_or_else(|error| { + // 可选 bearer:无效 token 按匿名处理,绝不能因为它挡掉一次真实游玩。 + debug!(error = %error, "游戏游玩计数忽略无效 bearer,按匿名计数"); + None + }); + let identity = authenticated + .as_ref() + .map(|token| format!("user:{}", token.claims().user_id())) + .or_else(|| request_client_id(&body).map(|client_id| format!("client:{client_id}"))) + .unwrap_or_else(|| format!("ip:{client_ip}|ua:{user_agent}")); + + let outcome = state.game_play_counter().record( + GamePlayReport { + game_id: &game_id, + identity: &identity, + client_ip: &client_ip, + }, + Instant::now(), + ); + if outcome == GamePlayOutcome::RateLimited { + return Err(AppError::from_status(StatusCode::TOO_MANY_REQUESTS)); + } + Ok(json_success_body( + Some(&ctx), + json!({ "recorded": outcome == GamePlayOutcome::Counted }), + )) +} + +fn request_client_id(body: &Bytes) -> Option { + if body.is_empty() { + return None; + } + let request = serde_json::from_slice::(body).ok()?; + request + .client_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.chars().take(128).collect()) +} + +fn user_agent_tag(headers: &HeaderMap) -> String { + headers + .get(header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown") + .chars() + .take(64) + .collect() +} + /// 作者自有游戏列表:只返回当前认证主体名下的游戏与最近版本状态。 async fn list_my_games( State(state): State, @@ -4076,4 +4187,53 @@ mod tests { assert_eq!(generic.summary, "一款由陶泥儿创作的益智游戏"); assert_eq!(generic.category, "益智"); } + + #[tokio::test] + async fn game_play_route_is_public_and_no_store_without_spacetime_connection() { + use axum::http::Request; + use tower::ServiceExt; + + let app = + crate::app::build_router(AppState::new(crate::config::AppConfig::default()).unwrap()); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/game-distribution/games/game_1/plays") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"clientId":"client-1"}"#)) + .unwrap(), + ) + .await + .unwrap(); + // 未连接 SpacetimeDB 时公开可见性读取失败,但路由可达且不需要登录;只有确认公开后才计数。 + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + } + + #[test] + fn play_request_client_id_trims_limits_and_rejects_blank() { + assert_eq!(request_client_id(&Bytes::from_static(b"")), None); + assert_eq!(request_client_id(&Bytes::from_static(b"not json")), None); + assert_eq!(request_client_id(&Bytes::from_static(b"{}")), None); + assert_eq!( + request_client_id(&Bytes::from_static(br#"{"clientId":" abc "}"#)), + Some("abc".to_string()) + ); + assert_eq!( + request_client_id(&Bytes::from_static(br#"{"clientId":" "}"#)), + None + ); + let long = "x".repeat(200); + let body = Bytes::from(format!(r#"{{"clientId":"{long}"}}"#)); + assert_eq!(request_client_id(&body).unwrap().chars().count(), 128); + } + + #[test] + fn play_report_user_agent_is_bounded_and_falls_back() { + assert_eq!(user_agent_tag(&HeaderMap::new()), "unknown"); + let mut headers = HeaderMap::new(); + headers.insert(header::USER_AGENT, " test-agent ".parse().unwrap()); + assert_eq!(user_agent_tag(&headers), "test-agent"); + } } diff --git a/server-rs/crates/api-server/src/request_context.rs b/server-rs/crates/api-server/src/request_context.rs index 57a109d28..9a1f58743 100644 --- a/server-rs/crates/api-server/src/request_context.rs +++ b/server-rs/crates/api-server/src/request_context.rs @@ -2,7 +2,7 @@ use std::time::{Duration, Instant}; use axum::{ extract::Request, - http::{HeaderValue, Request as HttpRequest, header::HeaderName}, + http::{HeaderMap, HeaderValue, Request as HttpRequest, header::HeaderName}, middleware::Next, response::Response, }; @@ -107,6 +107,29 @@ pub async fn attach_request_context(mut request: Request, next: Next) -> Respons .await } +/// 从代理头解析客户端 IP。 +/// +/// 优先 `x-real-ip`:nginx 用 `$remote_addr` 覆盖写入,是真实 TCP 对端,调用方无法伪造。 +/// `x-forwarded-for` 只作回退,并取**最后一段**——nginx 用 `$proxy_add_x_forwarded_for` 会把真实 +/// 对端追加在末尾,前面几段是调用方自带的、可伪造。两者都拿不到时才兜底回环地址。 +pub fn client_ip_from_headers(headers: &HeaderMap) -> String { + headers + .get("x-real-ip") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { + headers + .get("x-forwarded-for") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.rsplit(',').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + .unwrap_or("127.0.0.1") + .to_string() +} + pub fn resolve_request_id(request: &HttpRequest) -> Option { request .extensions() @@ -148,4 +171,35 @@ mod tests { assert_eq!(context.external_call_deadline(), None); } + + #[test] + fn client_ip_prefers_real_ip_over_forwarded_for() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + HeaderValue::from_static("203.0.113.7, 10.0.0.1"), + ); + headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.9")); + assert_eq!(client_ip_from_headers(&headers), "198.51.100.9"); + } + + #[test] + fn client_ip_forwarded_for_fallback_uses_last_address() { + // nginx 把真实对端追加在末尾,前面是调用方可伪造的值,只能取最后一段。 + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + HeaderValue::from_static("203.0.113.7, 10.0.0.1"), + ); + assert_eq!(client_ip_from_headers(&headers), "10.0.0.1"); + } + + #[test] + fn client_ip_ignores_blank_real_ip_and_falls_back_then_loopback() { + let mut headers = HeaderMap::new(); + headers.insert("x-real-ip", HeaderValue::from_static(" ")); + headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.1")); + assert_eq!(client_ip_from_headers(&headers), "10.0.0.1"); + assert_eq!(client_ip_from_headers(&HeaderMap::new()), "127.0.0.1"); + } } diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 0677ef55b..74b18d86c 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1740,21 +1740,7 @@ fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool { } fn resolve_wechat_pay_client_ip(headers: &HeaderMap) -> String { - headers - .get("x-forwarded-for") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.split(',').next()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .or_else(|| { - headers - .get("x-real-ip") - .and_then(|value| value.to_str().ok()) - .map(str::trim) - .filter(|value| !value.is_empty()) - }) - .unwrap_or("127.0.0.1") - .to_string() + crate::request_context::client_ip_from_headers(headers) } async fn resolve_wechat_identity_for_payment( diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index a7b970bfc..e89cb4829 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -45,6 +45,7 @@ use crate::editor_generation_config::{ EditorGenerationPricingConfig, EditorGenerationPricingError, EditorGenerationPricingStore, EditorGenerationPricingUnit, }; +use crate::game_play_counter::{GamePlayCounter, GamePlayCounterSettings}; use crate::tracking_outbox::TrackingOutbox; use crate::wallet_refund_outbox::{ProfileWalletRefundOutboxWorker, WalletRefundOutbox}; use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error}; @@ -312,6 +313,8 @@ pub struct AppStateInner { tracking_outbox: Option>, wallet_refund_outbox: Option>, profile_wallet_refund_outbox_worker: Arc, + /// 游玩计数的进程内聚合缓冲;写入由 `game_play_counter_worker` 负责。 + game_play_counter: GamePlayCounter, editor_generation_pricing_store: EditorGenerationPricingStore, llm_client: Option, vector_engine_llm_client: Option, @@ -617,6 +620,13 @@ impl AppState { WalletRefundOutbox::from_config(&config, spacetime_client.clone()); let profile_wallet_refund_outbox_worker = ProfileWalletRefundOutboxWorker::from_config(&config, spacetime_client.clone()); + let game_play_counter = GamePlayCounter::new( + GamePlayCounterSettings { + flush_interval: config.game_play_counter_flush_interval, + ..GamePlayCounterSettings::default() + }, + std::time::Instant::now(), + ); let editor_generation_pricing_store = EditorGenerationPricingStore::load( config.editor_generation_pricing_override_path.clone(), ) @@ -713,6 +723,7 @@ impl AppState { tracking_outbox, wallet_refund_outbox, profile_wallet_refund_outbox_worker, + game_play_counter, editor_generation_pricing_store, llm_client, vector_engine_llm_client, @@ -1691,6 +1702,10 @@ impl AppState { self.profile_wallet_refund_outbox_worker.clone() } + pub fn game_play_counter(&self) -> &GamePlayCounter { + &self.game_play_counter + } + pub fn llm_client(&self) -> Option<&LlmClient> { self.llm_client.as_ref() } diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 50e5bf74b..51df31e98 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -72,10 +72,17 @@ const MAIN_SPA_PATHS: &[&str] = &[ "/games/mine", "/games/play", "/games/publish", + "/pay", "/profile", + "/profile/payment", "/project", ]; +// 带动态段的前缀路由,必须与前端 `APP_PREFIX_ROUTE_ENTRIES` 以及三份 nginx 模板的 +// `location ~* "^/pay/[^/]+/?$"` 同口径:只放行「前缀 + 恰好一个路径段」,裸前缀由 +// `MAIN_SPA_PATHS` 负责;`npm run check:pingora-route-parity` 会逐条比对这份 allowlist。 +const MAIN_SPA_PREFIX_PATHS: &[&str] = &["/pay"]; + #[derive(Clone, Debug)] struct GatewayConfig { listen_addr: String, @@ -1955,6 +1962,21 @@ fn is_main_spa_path(path: &str) -> bool { MAIN_SPA_PATHS .iter() .any(|candidate| normalized.eq_ignore_ascii_case(candidate)) + || is_main_spa_prefix_path(normalized) +} + +/// 前缀路由(带动态段):`<前缀>/<恰好一个路径段>`,大小写不敏感(与 nginx `location ~*` 同口径)。 +/// 裸前缀、多段路径和前缀同名邻居(如 `/payment/x`)都不算命中。 +fn is_main_spa_prefix_path(normalized: &str) -> bool { + let mut segments = normalized.trim_start_matches('/').split('/'); + let (Some(first), Some(second), None) = (segments.next(), segments.next(), segments.next()) + else { + return false; + }; + !second.is_empty() + && MAIN_SPA_PREFIX_PATHS + .iter() + .any(|prefix| prefix.trim_start_matches('/').eq_ignore_ascii_case(first)) } fn is_maintenance_page_asset(path: &str) -> bool { @@ -3310,6 +3332,36 @@ mod tests { } } + #[test] + fn pay_checkout_deep_link_uses_main_spa_prefix_route() { + let spa_fallback = RouteDecision::Local(LocalResponse::Static { + root: StaticRoot::Web, + mode: StaticMode::SpaFallback, + }); + // 裸前缀由 MAIN_SPA_PATHS 精确命中,收银台深链 `/pay/` 由前缀路由命中, + // 两者都与 Nginx 的 `location ~* "^/pay/[^/]+/?$"` 同口径(大小写不敏感)。 + for path in [ + "/pay", + "/pay/", + "/PAY", + "/pay/checkout-token", + "/PAY/CheckOutToken/", + ] { + assert_eq!(classify_path(path), spa_fallback, "path: {path}"); + } + // 多段路径与前缀同名邻居不允许被吞进 SPA fallback。 + for path in ["/pay/two/segments", "/payment/token", "/paycheckout/token"] { + assert_eq!( + classify_path(path), + RouteDecision::Local(LocalResponse::Static { + root: StaticRoot::Web, + mode: StaticMode::Exact, + }), + "path: {path}" + ); + } + } + #[test] fn applies_configured_body_limit_to_generic_api_routes_only() { let mut generic_api = classify_path("/api/assets/history"); diff --git a/server-rs/crates/platform-llm/src/observability_tests.rs b/server-rs/crates/platform-llm/src/observability_tests.rs index 16cb87dfc..a166c9b7b 100644 --- a/server-rs/crates/platform-llm/src/observability_tests.rs +++ b/server-rs/crates/platform-llm/src/observability_tests.rs @@ -11,7 +11,6 @@ use tokio::sync::oneshot; use tracing::{ Instrument, Subscriber, field::{Field, Visit}, - instrument::WithSubscriber, span::{Attributes, Id, Record}, }; use tracing_subscriber::{Layer, layer::Context, prelude::*, registry::LookupSpan}; @@ -222,8 +221,70 @@ fn request() -> LlmRunRequest { .with_model("requested-model") } +/// 在整段流程期间把 capture 固定为当前 dispatcher。 +/// +/// tracing 的 callsite interest 是**进程级**缓存,且只在调用点首次被命中时计算一次 +/// (`DefaultCallsite::register` → `DISPATCHERS.rebuilder()`;进程里只有一个 dispatcher 时会 +/// 退化成命中线程自己的 dispatcher)。并发跑测试时,本文件之外那些没有 subscriber 的测试线程 +/// 只要抢到 `llm.request` 调用点的首次注册,它就会被永久缓存成 `never`,`span!` 宏随后静默 +/// 跳过 span 创建,`provider_span` 便会看到 0 个 span。 +/// +/// 注意:`set_default` 与 `with_subscriber` 在 interest 缓存这件事上**等价**——两者都只是新建一个 +/// `Dispatch` 并触发一次 `rebuild_interest`,只能纠正“已经注册过”的调用点,纠正不了 +/// `Rebuilder::JustOne → get_default() → 缓存 never` 这条首次注册分支;真正把调用点救回来的是 +/// `warm_up_provider_span_callsite` 在自家 subscriber 下命中同一调用点(覆盖两种顺序, +/// 并且把 dispatcher 从“只在每次 poll 生效”变成整段流程生效)。 +async fn run_under_capture(capture: &Capture, future: F) -> F::Output { + // `set_default` 是线程绑定的:本文件用的是默认 `#[tokio::test]`(current-thread 运行时, + // 被测 future 与断言在同一条线程上)。若以后改成 multi_thread 或把流程 spawn 出去, + // 必须换回 `with_current_subscriber()` / `with_subscriber`,否则 span 会在没有 dispatcher 的线程上丢。 + let _guard = + tracing::subscriber::set_default(tracing_subscriber::registry().with(capture.clone())); + future.await +} + +/// 让 `llm.request` 调用点在本进程内稳定可用:用一个必然失败的最小请求(指向刚释放的 +/// loopback 端口,连接直接被拒绝)命中同一个调用点,直到连续两轮都能采集到 span 为止。 +/// 请求失败无妨——只要 `llm.request` span 被创建就说明调用点在我们的 subscriber 下注册成功。 +/// 不 sleep、不放宽断言。 +async fn warm_up_provider_span_callsite() { + let listener = TcpListener::bind("127.0.0.1:0").expect("warmup listener should bind"); + let address = listener + .local_addr() + .expect("warmup address should resolve"); + drop(listener); + let config = LlmConfig::new( + LlmProvider::OpenAiCompatible, + format!("http://{address}"), + PRIVATE_KEY.into(), + "default-model".into(), + 500, + 0, + 1, + ) + .expect("warmup config should build"); + let client = LlmClient::new(config).expect("warmup client should build"); + let mut captured_rounds = 0; + for _ in 0..8 { + let probe = Capture::default(); + { + let _guard = tracing::subscriber::set_default( + tracing_subscriber::registry().with(probe.clone()), + ); + let _ = client.run(request()).await; + } + let captured = !probe.spans.lock().expect("capture should lock").is_empty(); + captured_rounds = if captured { captured_rounds + 1 } else { 0 }; + if captured_rounds >= 2 { + return; + } + } + panic!("无法在测试 subscriber 下恢复 llm.request 调用点的 interest 缓存"); +} + #[tokio::test] async fn provider_span_covers_awaited_execution_and_keeps_parent_without_arguments() { + warm_up_provider_span_callsite().await; let fixture = provider_fixture( "200 OK", "application/json", @@ -231,7 +292,7 @@ async fn provider_span_covers_awaited_execution_and_keeps_parent_without_argumen ); let capture = Capture::default(); let response = - async { + run_under_capture(&capture, async { async { let mut future = Box::pin(fixture.client.run(request())); assert!(capture.spans.lock().unwrap().values().all(|span| span.name != "llm.request")); @@ -245,8 +306,7 @@ async fn provider_span_covers_awaited_execution_and_keeps_parent_without_argumen fixture.release.send(()).unwrap(); future.await.unwrap() }.instrument(tracing::info_span!("test.request")).await - } - .with_subscriber(tracing_subscriber::registry().with(capture.clone())) + }) .await; fixture.server.join().unwrap(); assert_eq!(response.text, "completed"); @@ -255,6 +315,7 @@ async fn provider_span_covers_awaited_execution_and_keeps_parent_without_argumen #[tokio::test] async fn stream_callbacks_inherit_provider_span_and_keep_result() { + warm_up_provider_span_callsite().await; let fixture = provider_fixture( "200 OK", "text/event-stream", @@ -263,7 +324,7 @@ async fn stream_callbacks_inherit_provider_span_and_keep_result() { let capture = Capture::default(); let mut deltas = Vec::new(); let response = - async { + run_under_capture(&capture, async { async { let mut future = Box::pin(fixture.client.stream_run(request(), |delta| { tracing::info!(target: "llm_observability_test_delta", "delta received"); @@ -276,8 +337,7 @@ async fn stream_callbacks_inherit_provider_span_and_keep_result() { fixture.release.send(()).unwrap(); future.await.unwrap() }.instrument(tracing::info_span!("test.request")).await - } - .with_subscriber(tracing_subscriber::registry().with(capture.clone())) + }) .await; fixture.server.join().unwrap(); assert_eq!(response.text, "hello"); @@ -291,21 +351,21 @@ async fn stream_callbacks_inherit_provider_span_and_keep_result() { #[tokio::test] async fn provider_span_preserves_upstream_errors_and_closes_on_cancellation() { + warm_up_provider_span_callsite().await; let fixture = provider_fixture( "401 Unauthorized", "application/json", r#"{"error":{"message":"upstream-rejected"}}"#, ); let capture = Capture::default(); - let error = async { + let error = run_under_capture(&capture, async { async { fixture.release.send(()).unwrap(); fixture.client.run(request()).await.unwrap_err() } .instrument(tracing::info_span!("test.request")) .await - } - .with_subscriber(tracing_subscriber::registry().with(capture.clone())) + }) .await; fixture.server.join().unwrap(); assert!( @@ -315,7 +375,7 @@ async fn provider_span_preserves_upstream_errors_and_closes_on_cancellation() { let fixture = provider_fixture("200 OK", "application/json", "{}"); let capture = Capture::default(); - async { + run_under_capture(&capture, async { async { let mut future = Box::pin(fixture.client.run(request())); tokio::select! { @@ -326,7 +386,8 @@ async fn provider_span_preserves_upstream_errors_and_closes_on_cancellation() { drop(future); fixture.release.send(()).unwrap(); }.instrument(tracing::info_span!("test.request")).await - }.with_subscriber(tracing_subscriber::registry().with(capture.clone())).await; + }) + .await; fixture.server.join().unwrap(); capture.assert_completed("run"); } diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 1d4dc086c..5d275a1a5 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -27,10 +27,11 @@ pub use game_distribution::{ GameDistributionConfirmPackageRecordInput, GameDistributionCreateGameRecordInput, GameDistributionCreateVersionRecordInput, GameDistributionFailUploadRecordInput, GameDistributionGetGameRecordInput, GameDistributionOwnerGameListRecordInput, - GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput, - GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, - GameDistributionReviewModerationRecordInput, GameDistributionSubmitReviewRecordInput, - GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput, + GameDistributionPlayCountIncrementRecordInput, GameDistributionPublicGameListRecordInput, + GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, + GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationRecordInput, + GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, + GameDistributionUnpublishRecordInput, }; #[path = "active/external_generation.rs"] pub mod external_generation; diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 6e45c41a1..7e293a8f1 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -51,6 +51,13 @@ pub struct GameDistributionGetGameRecordInput { pub owner_user_id: Option, } +/// 单次游玩计数增量;同一批次内每个 `game_id` 只出现一次。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameDistributionPlayCountIncrementRecordInput { + pub game_id: String, + pub delta: u64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct GameDistributionCreateGameRecordInput { pub game_id: String, @@ -560,6 +567,49 @@ impl SpacetimeClient { .await } + /// 批量累加已公开游戏的游玩次数;非公开游戏由事务静默跳过。 + pub async fn increment_game_distribution_game_play_counts( + &self, + increments: Vec, + ) -> Result<(), SpacetimeClientError> { + let input = crate::module_bindings::GameDistributionPlayCountIncrementInput { + increments: increments + .into_iter() + .map( + |increment| crate::module_bindings::GameDistributionPlayCountIncrement { + game_id: increment.game_id, + delta: increment.delta, + }, + ) + .collect(), + }; + self.call_after_connect( + "increment_game_distribution_game_play_counts", + move |connection, sender| { + connection + .procedures() + .increment_game_distribution_game_play_counts_and_return_then( + input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(|result| { + if result.ok { + Ok(()) + } else { + Err(SpacetimeClientError::procedure_failed( + result.error_message, + )) + } + }); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn create_game_distribution_game( &self, input: GameDistributionCreateGameRecordInput, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index b865b2c03..d7ef52226 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -435,6 +435,8 @@ pub mod game_distribution_owner_game_list_input_type; pub mod game_distribution_owner_game_list_procedure_result_type; pub mod game_distribution_owner_game_snapshot_type; pub mod game_distribution_owner_version_input_type; +pub mod game_distribution_play_count_increment_input_type; +pub mod game_distribution_play_count_increment_type; pub mod game_distribution_procedure_result_type; pub mod game_distribution_public_game_input_type; pub mod game_distribution_public_game_list_input_type; @@ -505,6 +507,7 @@ pub mod import_database_migration_from_chunks_procedure; pub mod import_database_migration_from_file_procedure; pub mod import_database_migration_incremental_from_chunks_procedure; pub mod import_database_migration_incremental_from_file_procedure; +pub mod increment_game_distribution_game_play_counts_and_return_procedure; pub mod initialize_editor_generation_pricing_config_if_missing_and_return_procedure; pub mod list_admin_accounts_and_return_procedure; pub mod list_admin_game_distribution_games_and_return_procedure; @@ -1310,6 +1313,8 @@ pub use game_distribution_owner_game_list_input_type::GameDistributionOwnerGameL pub use game_distribution_owner_game_list_procedure_result_type::GameDistributionOwnerGameListProcedureResult; pub use game_distribution_owner_game_snapshot_type::GameDistributionOwnerGameSnapshot; pub use game_distribution_owner_version_input_type::GameDistributionOwnerVersionInput; +pub use game_distribution_play_count_increment_input_type::GameDistributionPlayCountIncrementInput; +pub use game_distribution_play_count_increment_type::GameDistributionPlayCountIncrement; pub use game_distribution_procedure_result_type::GameDistributionProcedureResult; pub use game_distribution_public_game_input_type::GameDistributionPublicGameInput; pub use game_distribution_public_game_list_input_type::GameDistributionPublicGameListInput; @@ -1380,6 +1385,7 @@ pub use import_database_migration_from_chunks_procedure::import_database_migrati pub use import_database_migration_from_file_procedure::import_database_migration_from_file; pub use import_database_migration_incremental_from_chunks_procedure::import_database_migration_incremental_from_chunks; pub use import_database_migration_incremental_from_file_procedure::import_database_migration_incremental_from_file; +pub use increment_game_distribution_game_play_counts_and_return_procedure::increment_game_distribution_game_play_counts_and_return; pub use initialize_editor_generation_pricing_config_if_missing_and_return_procedure::initialize_editor_generation_pricing_config_if_missing_and_return; pub use list_admin_accounts_and_return_procedure::list_admin_accounts_and_return; pub use list_admin_game_distribution_games_and_return_procedure::list_admin_game_distribution_games_and_return; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_play_count_increment_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_play_count_increment_input_type.rs new file mode 100644 index 000000000..bad79b6bc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_play_count_increment_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::game_distribution_play_count_increment_type::GameDistributionPlayCountIncrement; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionPlayCountIncrementInput { + pub increments: Vec, +} + +impl __sdk::InModule for GameDistributionPlayCountIncrementInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_play_count_increment_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_play_count_increment_type.rs new file mode 100644 index 000000000..04cc33724 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_play_count_increment_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionPlayCountIncrement { + pub game_id: String, + pub delta: u64, +} + +impl __sdk::InModule for GameDistributionPlayCountIncrement { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs index bc951678a..19fe4595c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_editor_asset_folder_and_return { input: EditorAssetFolderGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_editor_asset_folder_and_return for super::RemoteProcedures { input: EditorAssetFolderGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAssetFolderProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/increment_game_distribution_game_play_counts_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/increment_game_distribution_game_play_counts_and_return_procedure.rs new file mode 100644 index 000000000..58877ba24 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/increment_game_distribution_game_play_counts_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::game_distribution_play_count_increment_input_type::GameDistributionPlayCountIncrementInput; +use super::game_distribution_procedure_result_type::GameDistributionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct IncrementGameDistributionGamePlayCountsAndReturnArgs { + pub input: GameDistributionPlayCountIncrementInput, +} + +impl __sdk::InModule for IncrementGameDistributionGamePlayCountsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `increment_game_distribution_game_play_counts_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait increment_game_distribution_game_play_counts_and_return { + fn increment_game_distribution_game_play_counts_and_return( + &self, + input: GameDistributionPlayCountIncrementInput, + ) { + self.increment_game_distribution_game_play_counts_and_return_then(input, |_, _| {}); + } + + fn increment_game_distribution_game_play_counts_and_return_then( + &self, + input: GameDistributionPlayCountIncrementInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl increment_game_distribution_game_play_counts_and_return for super::RemoteProcedures { + fn increment_game_distribution_game_play_counts_and_return_then( + &self, + input: GameDistributionPlayCountIncrementInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionProcedureResult>( + "increment_game_distribution_game_play_counts_and_return", + IncrementGameDistributionGamePlayCountsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 1d4a59cde..9c0ba70a5 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1062,6 +1062,19 @@ pub struct GameDistributionPublicGameInput { pub game_id: String, } +/// 单次游玩计数增量;api-server 已按 `game_id` 聚合,同一输入内不重复。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionPlayCountIncrement { + pub game_id: String, + pub delta: u64, +} + +/// 一次 flush 的批量游玩计数增量。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionPlayCountIncrementInput { + pub increments: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionOwnerVersionInput { pub owner_user_id: String, @@ -1494,6 +1507,34 @@ pub fn restore_game_distribution_game_and_return( } } +/// 批量累加已公开游戏的游玩次数。 +/// +/// 只对当前 `published` 且存在有效 `active_version_id` 的游戏生效,非公开游戏静默跳过; +/// 自增不修改 `updated_at`,避免每次游玩重排作者自有列表。 +#[spacetimedb::procedure] +pub fn increment_game_distribution_game_play_counts_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionPlayCountIncrementInput, +) -> GameDistributionProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + increment_game_distribution_game_play_counts_tx(tx, input.clone()) + }) { + Ok(()) => GameDistributionProcedureResult { + ok: true, + replayed: false, + game: None, + games: Vec::new(), + version: None, + versions: Vec::new(), + next_cursor: None, + error_message: None, + }, + Err(error) => game_distribution_result_error(error), + } +} + /// 返回作者名下的游戏与最近版本;owner 来自 api-server 的认证主体,调用方不能指定他人。 #[spacetimedb::procedure] pub fn list_owner_game_distribution_games_and_return( @@ -2771,6 +2812,36 @@ fn restore_game_distribution_game_tx( Ok((game_distribution_game_snapshot(&game), false)) } +/// 批量累加游玩次数;只作用于当前公开且有有效公开版本的游戏。 +/// +/// 同一 `game_id` 的多次增量由 api-server 侧先聚合;这里仍逐条 `find`,对同一行的 +/// 多条增量按顺序 `saturating_add`,因此聚合与否都不影响结果。 +fn increment_game_distribution_game_play_counts_tx( + ctx: &ReducerContext, + input: GameDistributionPlayCountIncrementInput, +) -> Result<(), String> { + for increment in input.increments { + if increment.delta == 0 { + continue; + } + let game_id = match normalize_game_distribution_optional(increment.game_id) { + Some(game_id) => game_id, + None => continue, + }; + let Some(mut game) = ctx.db.game_distribution_game().game_id().find(&game_id) else { + continue; + }; + if game.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED + || game.active_version_id.is_none() + { + continue; + } + game.play_count = game.play_count.saturating_add(increment.delta); + ctx.db.game_distribution_game().game_id().update(game); + } + Ok(()) +} + fn list_admin_game_distribution_games_tx( ctx: &ReducerContext, input: GameDistributionAdminGameListInput, diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 879a4b223..471613f14 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -18,11 +18,13 @@ import { GAME_PLAY_STARTUP_TIMEOUT_MS, GamePlayPage } from './GamePlayPage'; const listGamesMock = vi.hoisted(() => vi.fn()); const getGameMock = vi.hoisted(() => vi.fn()); +const recordGamePlayMock = vi.hoisted(() => vi.fn()); const resolvedAssetUrlMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/gameDistributionClient', () => ({ listGames: listGamesMock, getGame: getGameMock, + recordGamePlay: recordGamePlayMock, listGameReviews: vi.fn().mockResolvedValue({ reviews: [], page: 1, @@ -114,6 +116,7 @@ beforeEach(() => { setViewportWidth(1024); listGamesMock.mockResolvedValue([]); getGameMock.mockResolvedValue(createGame()); + recordGamePlayMock.mockResolvedValue(undefined); resolvedAssetUrlMock.mockImplementation((objectKey: string) => objectKey ? `https://assets.example.test/${objectKey}` : '', ); @@ -229,6 +232,7 @@ describe('GamePlayPage', () => { fireEvent.click(screen.getByRole('button', { name: /开始游戏/u })); const iframe = await screen.findByTitle('星轨防线 在线游玩'); expect(iframe.getAttribute('sandbox')).toBe('allow-scripts'); + expect(recordGamePlayMock).toHaveBeenCalledWith('game-1'); }); it('rejects an unsafe release entry URL before rendering an iframe', async () => { diff --git a/src/components/game-distribution/GamePlayPage.tsx b/src/components/game-distribution/GamePlayPage.tsx index ecd1df9d5..f4f1efebd 100644 --- a/src/components/game-distribution/GamePlayPage.tsx +++ b/src/components/game-distribution/GamePlayPage.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { type GameDistributionGame, getGame, + recordGamePlay, } from '../../services/gameDistributionClient'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; @@ -105,6 +106,11 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { Boolean(game) && game?.orientation === 'landscape' && isPortraitViewport; const startGame = () => { if (mobilePlaybackBlocked) return; + // 计数是 fire-and-forget:点击即视为一次游玩,上报失败也不阻断进入游戏。 + const playGameId = game?.id ?? normalizeGameId(gameId); + if (playGameId) { + void recordGamePlay(playGameId).catch(() => {}); + } setHasStarted(true); setStartupState('loading'); setFrameRunId((runId) => runId + 1); diff --git a/src/routing/activeAppPageRoutes.ts b/src/routing/activeAppPageRoutes.ts index 8c91e2e6e..0c32ec9e9 100644 --- a/src/routing/activeAppPageRoutes.ts +++ b/src/routing/activeAppPageRoutes.ts @@ -18,6 +18,16 @@ const STAGE_ROUTE_ENTRIES = [ ['game-publish', '/games/publish'], ] as const satisfies readonly (readonly [SelectionStage, string])[]; +/** + * 带动态段、需要整体回退 index.html 的对外路由前缀 → 归属 stage。 + * 例如收银台深链 `/pay/`(后端 `payment.rs` 用 `format!("/pay/{}", token)` 生成, + * 前端从最后一个路径段读 token)。Nginx 必须按「前缀 + 恰好一个路径段」的锚定形状放行: + * 只放行裸前缀会让真实收银台链接落到默认 location 变成 404,放行过宽又会把未知路径吞掉。 + */ +export const APP_PREFIX_ROUTE_ENTRIES = [ + ['/pay', 'payment-checkout'], +] as const satisfies readonly (readonly [string, SelectionStage])[]; + export const APP_STAGE_ROUTES: Record = Object.fromEntries(STAGE_ROUTE_ENTRIES) as Record; @@ -41,10 +51,13 @@ export function normalizeAppPath(pathname: string) { export function resolveSelectionStageFromPath( pathname: string, ): SelectionStage { - if (normalizeAppPath(pathname).startsWith('/pay/')) { - return 'payment-checkout'; + const normalizedPath = normalizeAppPath(pathname); + for (const [prefix, stage] of APP_PREFIX_ROUTE_ENTRIES) { + if (normalizedPath.startsWith(`${prefix}/`)) { + return stage; + } } - return ROUTE_STAGE_BY_PATH.get(normalizeAppPath(pathname)) ?? 'platform'; + return ROUTE_STAGE_BY_PATH.get(normalizedPath) ?? 'platform'; } export function resolveInitialSelectionStageFromPath( diff --git a/src/services/gameDistributionClient.test.ts b/src/services/gameDistributionClient.test.ts index 67318f59b..3f85160dc 100644 --- a/src/services/gameDistributionClient.test.ts +++ b/src/services/gameDistributionClient.test.ts @@ -11,6 +11,7 @@ import { listGameReviews, listGames, listMyGames, + recordGamePlay, saveMyGameReview, unpublishGame, uploadGamePackage, @@ -83,6 +84,38 @@ describe('gameDistributionClient', () => { expect(await getGame('')).toBeNull(); }); + it('游玩上报携带稳定的匿名 clientId,且不携带未登录 bearer', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ recorded: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await recordGamePlay('game-1'); + await recordGamePlay('game-1'); + + const playCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes('/plays'), + ); + expect(playCalls).toHaveLength(2); + expect(playCalls[0]?.[0]).toBe('/api/game-distribution/games/game-1/plays'); + expect(playCalls[0]?.[1]).toMatchObject({ + method: 'POST', + cache: 'no-store', + }); + expect(playCalls[0]?.[1]?.headers?.Authorization).toBeUndefined(); + const clientIds = playCalls.map( + ([, init]) => + (JSON.parse(String(init?.body)) as { clientId?: string }).clientId, + ); + expect(clientIds[0]).toBeTruthy(); + expect(clientIds).toEqual([clientIds[0], clientIds[0]]); + }); + it('版本创建携带幂等键并保留后端失败状态', async () => { setStoredAccessToken('test-access-token', { emit: false }); vi.stubGlobal( diff --git a/src/services/gameDistributionClient.ts b/src/services/gameDistributionClient.ts index a1b7567c9..dfb89898a 100644 --- a/src/services/gameDistributionClient.ts +++ b/src/services/gameDistributionClient.ts @@ -16,8 +16,10 @@ import type { } from '../../packages/shared/src/contracts/gameDistribution'; import { type ApiRequestOptions, + BACKGROUND_AUTH_REQUEST_OPTIONS, requestJson as requestApiJson, } from './apiClient'; +import { getGamePlayClientId } from './gamePlayClientId'; export type { GameDistributionAuthor, @@ -67,6 +69,11 @@ const PUBLIC_GAME_REQUEST_OPTIONS: ApiRequestOptions = { clearAuthOnUnauthorized: false, }; +/** + * 游玩上报是后台尽力而为的请求:带上已登录 bearer(后端据此按 userId 去重),但即使遇到 401 + * 也不刷新会话、不改动全局登录态——直接复用后台请求已有的同一份鉴权降级策略。 + */ + async function requestJson( path: string, init: RequestInit, @@ -171,6 +178,26 @@ export async function getGame(gameId: string) { ); } +/** + * 上报一次「开始游戏」。调用方按 fire-and-forget 处理:失败只静默忽略,绝不阻断进入游戏。 + */ +export function recordGamePlay( + gameId: string, +): Promise<{ recorded?: boolean }> { + const normalizedId = gameId.trim(); + if (!normalizedId) return Promise.resolve({ recorded: false }); + return requestJson<{ recorded?: boolean }>( + `/games/${encodeURIComponent(normalizedId)}/plays`, + { + method: 'POST', + cache: 'no-store', + body: JSON.stringify({ clientId: getGamePlayClientId() }), + }, + '游玩上报失败', + BACKGROUND_AUTH_REQUEST_OPTIONS, + ); +} + export function listGameReviews(gameId: string, page = 1) { return requestJson( `/games/${encodeURIComponent(gameId)}/reviews?page=${page}&pageSize=20`, diff --git a/src/services/gamePlayClientId.ts b/src/services/gamePlayClientId.ts new file mode 100644 index 000000000..c56c4b732 --- /dev/null +++ b/src/services/gamePlayClientId.ts @@ -0,0 +1,43 @@ +/** + * 匿名游玩身份。 + * + * 登录用户由后端从 bearer 取 `userId`,匿名用户只能靠前端持久一个随机 `clientId`。这里把 + * 生成、读取与降级收在一个小模块里:`localStorage` 不可用(隐私模式、SSR、被禁用)时退化为 + * 当前会话内存值,保证一次会话内多次点击仍去重。 + */ + +const GAME_PLAY_CLIENT_ID_STORAGE_KEY = 'genarrative.game-play.client-id.v1'; + +let cachedClientId: string | null = null; + +/** 返回稳定的匿名游玩身份;同一标签页内重复调用返回同一个值。 */ +export function getGamePlayClientId(): string { + if (cachedClientId) return cachedClientId; + + try { + const stored = window.localStorage + .getItem(GAME_PLAY_CLIENT_ID_STORAGE_KEY) + ?.trim(); + if (stored) { + cachedClientId = stored; + return stored; + } + const created = createGamePlayClientId(); + window.localStorage.setItem(GAME_PLAY_CLIENT_ID_STORAGE_KEY, created); + cachedClientId = created; + return created; + } catch { + cachedClientId = cachedClientId ?? createGamePlayClientId(); + return cachedClientId; + } +} + +function createGamePlayClientId(): string { + if ( + typeof crypto !== 'undefined' && + typeof crypto.randomUUID === 'function' + ) { + return crypto.randomUUID(); + } + return `gp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} diff --git a/vite.config.ts b/vite.config.ts index 142f05851..deaae8dd9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -88,6 +88,7 @@ const ACTIVE_TOP_LEVEL_SOURCE_MODULES = new Map>([ 'clipboard.ts', 'frontendRuntimeConfigService.ts', 'gameDistributionClient.ts', + 'gamePlayClientId.ts', 'sseStream.ts', ]), ],