Merge remote-tracking branch 'origin/master' into feat/pricing-plan
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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` 合并,用来守住"渠道配置不得丢窗口契约"。
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<u64> =
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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<ResourceReferenceInsertEventDetail>)
|
||||
.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<ResourceReferenceInsertManyEventDetail>
|
||||
).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,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+30
-1
@@ -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<DirectProjectComposerHandle | null>(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 (
|
||||
<section
|
||||
className="project-chat-surface is-direct-codex"
|
||||
@@ -252,6 +280,7 @@ export function DirectProjectChatView({
|
||||
</div>
|
||||
) : null}
|
||||
<DirectProjectComposer
|
||||
ref={composerHandleRef}
|
||||
assets={assets}
|
||||
{...(versions ? { versions } : {})}
|
||||
projectPath={projectPath}
|
||||
|
||||
+43
-2
@@ -1,10 +1,18 @@
|
||||
import { ArrowUp, AtSign } from 'lucide-react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type Ref,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
GameCreationAppAssetManifestEntry,
|
||||
GameIterationVersion,
|
||||
} from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { ActiveChatComposerHandle } from '../../../../../features/project-workspace/activeChatComposer';
|
||||
import {
|
||||
ConversationModelSelect,
|
||||
type ConversationModelSelectHandle,
|
||||
@@ -21,7 +29,10 @@ import {
|
||||
ResourceReferencePicker,
|
||||
type ResourceReferencePickerHandle,
|
||||
} from '../../../../../features/project-workspace/ResourceReferencePicker';
|
||||
import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences';
|
||||
import {
|
||||
assetsSignature,
|
||||
type ChatReference,
|
||||
} from '../../../../../features/project-workspace/resourceReferences';
|
||||
import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments';
|
||||
import type { DirectPendingTurn } from '../../conversation/directPendingTurns';
|
||||
import type { UserContentPart } from '../../generated/UserContentPart';
|
||||
@@ -40,6 +51,12 @@ function draftAttachmentCount(draft: { content: UserContentPart[] }) {
|
||||
).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入区对外的命令式出口:就是「活跃聊天输入区」注册表要的那两件事
|
||||
* (见 `features/project-workspace/activeChatComposer.ts`)。
|
||||
*/
|
||||
export type DirectProjectComposerHandle = ActiveChatComposerHandle;
|
||||
|
||||
/**
|
||||
* DirectProject 输入区:待发消息 chip、附件、`@` 引用输入框、模型/推理选择和发送/终止。
|
||||
*
|
||||
@@ -62,6 +79,7 @@ export function DirectProjectComposer({
|
||||
onReferencePickerOpen,
|
||||
onSubmit,
|
||||
onUploadFiles,
|
||||
ref,
|
||||
}: {
|
||||
assets: GameCreationAppAssetManifestEntry[];
|
||||
versions?: GameIterationVersion[];
|
||||
@@ -107,6 +125,11 @@ export function DirectProjectComposer({
|
||||
files: readonly File[],
|
||||
draftAttachmentCount: number,
|
||||
) => Promise<DirectCodexTurnAttachment[]>;
|
||||
/**
|
||||
* 输入区句柄出口:`DirectProjectChatView` 拿它注册「活跃聊天输入区」,
|
||||
* 画布的「引用」/拖拽批量引用事件才能落进这份草稿。
|
||||
*/
|
||||
ref?: Ref<DirectProjectComposerHandle>;
|
||||
}) {
|
||||
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
|
||||
const composerRootRef = useRef<HTMLDivElement | null>(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 = (
|
||||
|
||||
+19
-1
@@ -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<HTMLInputElement>(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**。
|
||||
|
||||
|
||||
@@ -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<ResourceDependencyOverlayHandle>;
|
||||
}) {
|
||||
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');
|
||||
|
||||
@@ -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<void>>()
|
||||
.fn<[input: ResourceEditSubmitInput], Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error('网络暂不可用'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
render(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
focus: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, (event: { payload: unknown }) => 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<string, unknown>) => Promise<unknown>,
|
||||
options: { withEvents?: boolean } = {},
|
||||
): AuthEventBridge {
|
||||
const listeners = new Map<string, (event: { payload: unknown }) => 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<string, unknown>) => Promise<unknown>,
|
||||
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 () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<typeof createProjectChatRuntimeHarness>;
|
||||
|
||||
/**
|
||||
* 把夹具的 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<string, unknown>],
|
||||
Promise<unknown>
|
||||
>;
|
||||
|
||||
function looseInvoke(harness: ChatRuntimeHarness): LooseInvokeMock {
|
||||
return harness.invoke as unknown as LooseInvokeMock;
|
||||
}
|
||||
|
||||
// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。
|
||||
const AppComponent = App as React.ComponentType<
|
||||
NonNullable<React.ComponentProps<typeof App>>
|
||||
>;
|
||||
|
||||
function renderDesignAgent(
|
||||
harness: ReturnType<typeof createProjectChatRuntimeHarness>,
|
||||
) {
|
||||
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<void>((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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
findResourceSelectButton,
|
||||
fireEvent,
|
||||
getResourceSelectButton,
|
||||
installTauriRuntime,
|
||||
it,
|
||||
nativeClipboardMock,
|
||||
pickProjectFromLauncher,
|
||||
@@ -28,6 +29,12 @@ import {
|
||||
within,
|
||||
} from './harness';
|
||||
|
||||
// App 的 props 参数带默认值,`React.ComponentProps<typeof App>` 会带上 undefined,
|
||||
// createElement 因此推断不出 props;这里收窄成非空组件类型,运行时仍是同一个组件。
|
||||
const TestApp = App as React.ComponentType<
|
||||
NonNullable<React.ComponentProps<typeof App>>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 外壳级浮层提示的挂点。
|
||||
*
|
||||
@@ -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<string, unknown>) => {
|
||||
// 回调体内要读 invoke.mock.calls,显式标注返回类型以打断类型推断的自引用环。
|
||||
async (
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<unknown> => {
|
||||
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 管理的应用数据目录。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user