Files
Genarrative/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx
kdletters c38d07044a 修复 AGC 弹窗打开时自绘标题栏窗口按钮失效
- ThemedModal 的焦点陷阱只放行落在 [data-window-chrome-bar] 内的点击,标题栏拖拽与最小化/最大化/关闭恢复可用,工作区内容点击仍被拦住
- WindowChrome 标题栏加 data-window-chrome-bar 标记,作为这条约定的唯一契约点
- styles.css 明确「全屏弹层一律从标题栏下方开始」,.app-update-overlay 从 inset:0 改为标题栏下方,.game-publish-progress-overlay 显式声明 top
- 新增 tests/windowChromeOverlayContract.test.ts 覆盖 7 个全屏弹层;themedModal / WindowChrome 用例补「标题栏点击放行 + 工作区点击仍被拦」回归;gamePublishFeedback 用例按新口径断言
- pitfalls 记录该静默失效的机制与现行口径
2026-09-23 19:05:33 +08:00

293 lines
9.1 KiB
TypeScript

// @vitest-environment jsdom
/**
* 客户端「发布」入口的可见反馈。
*
* 发布动作使用独立的全屏进度弹窗,不回到旧的聊天确认卡片;
* 失败必须留在弹窗内可见,成功后自动切换到发布资料面板。
*/
import { readFileSync } from 'node:fs';
import { beforeEach, describe, it, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
App,
createGameCreationAppManifest,
createProjectChatRuntimeHarness,
emptyProjectPolicy,
expect,
fireEvent,
render,
screen,
waitFor,
within,
} from './appSurface/harness';
import { repoPath } from './repoPath';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
} from './styleCascade';
const readGamePublishAvailabilityMock = vi.hoisted(() =>
vi.fn(async () => true),
);
vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('../src/services/gameDistributionPublish')
>();
return {
...actual,
readGamePublishAvailability: readGamePublishAvailabilityMock,
};
});
const PROJECT_PATH = '/tmp/game-publish-feedback-project';
const PROJECT_ID = 'game-publish-feedback-project';
function withPrototypeStatus(
manifest: GameCreationAppManifest,
status: 'pending' | 'completed',
): GameCreationAppManifest {
return {
...manifest,
tasks: manifest.tasks.map((task) =>
task.id === 'code-prototype' ? { ...task, status } : task,
),
};
}
function createFixtureManifest(): GameCreationAppManifest {
return withPrototypeStatus(
createGameCreationAppManifest(PROJECT_ID, '发布反馈项目'),
'completed',
);
}
function createPendingPrototypeManifest(): GameCreationAppManifest {
return withPrototypeStatus(
createGameCreationAppManifest(PROJECT_ID, '原型未完成项目'),
'pending',
);
}
function installTauri(
options: {
exportPackage?: () => unknown;
manifest?: GameCreationAppManifest;
policy?: ReturnType<typeof emptyProjectPolicy>;
readPolicy?: () => unknown;
} = {},
) {
const manifest = options.manifest ?? createFixtureManifest();
const chatHarness = createProjectChatRuntimeHarness({
projectPath: PROJECT_PATH,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_manifest') return manifest;
if (command === 'get_local_game_project_revision') return { revision: 1 };
if (command === 'read_project_permission_policy') {
if (options.readPolicy) return options.readPolicy();
return options.policy ?? emptyProjectPolicy();
}
if (command === 'export_local_project_package') {
if (options.exportPackage) return options.exportPackage();
return {
projectPath: PROJECT_PATH,
packagePath: `${PROJECT_PATH}/exports/game.zip`,
packageRelativePath: 'exports/game.zip',
fileCount: 1,
totalBytes: 1,
};
}
return chatHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke: invoke as never },
event: { listen: chatHarness.listen as never },
};
return { invoke };
}
function renderPublishProject(
manifest: GameCreationAppManifest = createFixtureManifest(),
) {
return render(
<App initialProjectManifest={manifest} initialProjectPath={PROJECT_PATH} />,
);
}
async function clickPublish() {
const publish = await screen.findByRole('button', {
name: '发布到游戏广场',
});
fireEvent.click(publish);
return screen.findByLabelText('陶泥儿项目对话');
}
beforeEach(() => {
window.history.pushState({}, '', '/');
readGamePublishAvailabilityMock.mockReset();
readGamePublishAvailabilityMock.mockResolvedValue(true);
});
function createExportPackageResult() {
return {
projectPath: PROJECT_PATH,
packagePath: `${PROJECT_PATH}/exports/game.zip`,
packageRelativePath: 'exports/game.zip',
fileCount: 1,
totalBytes: 1,
};
}
function createConfirmPolicy() {
return {
path: '.agent/policy.json',
policy: {
deniedCommands: [],
confirmCommands: ['project.export_package'],
},
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
describe('客户端发布入口的可见反馈', () => {
it('首个可运行原型未完成时显示独立阻断弹窗,不触发用户项目构建', async () => {
const exportPackage = vi.fn(createExportPackageResult);
const manifest = createPendingPrototypeManifest();
installTauri({ exportPackage, manifest });
renderPublishProject(manifest);
const surface = await clickPublish();
const noticeDialog = await screen.findByRole('dialog', {
name: '发布提示',
});
expect(noticeDialog.textContent ?? '').toContain(
'首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。',
);
expect(surface.textContent ?? '').not.toContain(
'首个可运行原型尚未完成,暂不能发布',
);
expect(surface.textContent ?? '').not.toContain('正在检查发布权限…');
expect(exportPackage).not.toHaveBeenCalled();
fireEvent.click(within(noticeDialog).getByRole('button', { name: '关闭' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '发布提示' })).toBeNull();
});
});
it('发布时显示全屏进度遮罩,成功后收起进度并打开发布面板', async () => {
const deferred =
createDeferred<ReturnType<typeof createExportPackageResult>>();
const exportPackage = vi.fn(() => deferred.promise);
installTauri({ exportPackage });
renderPublishProject();
fireEvent.click(
await screen.findByRole('button', { name: '发布到游戏广场' }),
);
const progressDialog = await screen.findByRole('dialog', {
name: '发布进度',
});
expect(progressDialog.textContent ?? '').toContain(
'正在构建并打包试玩包,请稍候…',
);
expect(progressDialog.textContent ?? '').toContain(
'发布完成前请保持客户端开启,页面暂时不可操作。',
);
expect(
progressDialog.closest('.game-publish-progress-overlay'),
).not.toBeNull();
expect(exportPackage).toHaveBeenCalledTimes(1);
expect(screen.queryByText('导出试玩包需要确认,确认后继续。')).toBeNull();
deferred.resolve(createExportPackageResult());
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '发布进度' })).toBeNull();
});
expect(
await screen.findByRole('dialog', { name: '发布到游戏广场' }),
).not.toBeNull();
});
it('发布失败时在进度弹窗里显示错误并允许关闭', async () => {
installTauri({
exportPackage: () => {
throw new Error('构建可玩版本失败:缺少入口');
},
});
renderPublishProject();
fireEvent.click(
await screen.findByRole('button', { name: '发布到游戏广场' }),
);
const failureDialog = await screen.findByRole('dialog', {
name: '发布失败',
});
expect(failureDialog.textContent ?? '').toContain(
'构建可玩版本失败:缺少入口',
);
fireEvent.click(
within(failureDialog).getByRole('button', { name: '关闭' }),
);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '发布失败' })).toBeNull();
});
});
it('发布进度遮罩固定覆盖整个工作区并压暗背景', () => {
const rules = parseStyleSheet(
readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
),
);
const overlay = resolveDeclarations(
rules,
['.game-publish-progress-overlay'],
1440,
);
expect(declaration(overlay, 'position')).toBe('fixed');
// 遮罩从自绘标题栏下方开始:发布进行中仍然要能最小化 / 关闭窗口。
expect(declaration(overlay, 'top')).toBe('var(--window-chrome-height)');
expect(declaration(overlay, 'right')).toBe('0');
expect(declaration(overlay, 'bottom')).toBe('0');
expect(declaration(overlay, 'left')).toBe('0');
expect(declaration(overlay, 'z-index')).toBe('500');
expect(declaration(overlay, 'pointer-events')).toBe('auto');
expect(declaration(overlay, 'background')).toBe('rgb(35 24 19 / 62%)');
});
it('策略要求确认时不再回到聊天确认卡片,直接进入进度弹窗', async () => {
const exportPackage = vi.fn(createExportPackageResult);
installTauri({ exportPackage, policy: createConfirmPolicy() });
renderPublishProject();
fireEvent.click(
await screen.findByRole('button', { name: '发布到游戏广场' }),
);
await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1));
expect(screen.queryByText('project.export_package')).toBeNull();
expect(screen.queryByText('导出试玩包需要确认,确认后继续。')).toBeNull();
});
});