c38d07044a
- 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 记录该静默失效的机制与现行口径
293 lines
9.1 KiB
TypeScript
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();
|
|
});
|
|
});
|