Files
lhk229 c229a353be
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (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
修复策划模式输入提示显示
策划模式保留输入框但隐藏占位文案

补充策划模式输入框显示回归测试
2026-09-15 18:12:10 +00:00

839 lines
31 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { PlanGddStageProgress } from '../../src/features/project-workspace/GddApprovalCard';
import {
act,
App,
createPlanGddStateView,
createProjectSupervisorRuntimeHarness,
expect,
fireEvent,
it,
React,
render,
screen,
vi,
waitFor,
within,
} from './harness';
function mountFormalSupervisor(
harness: ReturnType<typeof createProjectSupervisorRuntimeHarness>,
planningStartMode = false,
) {
window.history.pushState({}, '', '/');
render(
React.createElement(App, {
initialProjectPath: harness.projectPath,
orchestrationMode: 'single-supervisor',
planningStartMode,
projectSupervisorOnly: true,
}),
);
}
function planningV2ApprovalResult() {
const state = createPlanGddStateView();
const displayGdd = state.displayGdd;
const session = state.session;
if (!displayGdd || !session) {
throw new Error('fixture 应当带 GDD 和 session');
}
return planningV2ResultFor(
session,
displayGdd,
'awaiting_approval',
'ready_for_approval',
);
}
function planningV2ResultFor(
session: NonNullable<PlanGddStateView['session']>,
displayGdd: NonNullable<PlanGddStateView['displayGdd']>,
sessionStatus: string,
artifactStatus: string,
) {
return {
session: {
schemaVersion: 'planning-session.v2',
engine: 'planning-session-v2',
sessionId: session.sessionId,
projectId: 'local-project-draft',
mode: 'gdd',
status: sessionStatus,
turnIndex: 3,
questionCount: session.clarificationRound,
questionLimit: 8,
revisionCount: 0,
currentArtifactVersion: displayGdd.version,
currentQuestion: null,
capabilities: { tools: [], skills: [] },
processingSeconds: session.accumulatedAgentMillis / 1000,
createdAtUtc: displayGdd.createdAtUtc,
updatedAtUtc: displayGdd.createdAtUtc,
lastError: null,
},
result: null,
currentArtifact: {
artifactId: displayGdd.gddId,
kind: 'gdd',
version: displayGdd.version,
status: artifactStatus,
fingerprint: displayGdd.fingerprint,
payload: {
schemaVersion: 'plan-gdd.v2',
projectId: displayGdd.projectId,
gddId: displayGdd.gddId,
version: displayGdd.version,
createdAtUtc: displayGdd.createdAtUtc,
game: displayGdd.game,
decisions: displayGdd.decisions,
prototypeValidationItems: displayGdd.prototypeValidationItems,
fingerprint: displayGdd.fingerprint,
},
},
replayed: false,
conversation: [],
};
}
/** 已批准:审批卡整张收掉,只剩标题栏和它下面的交付行。 */
function planningV2ApprovedResult() {
const state = createPlanGddStateView();
const displayGdd = state.displayGdd;
const session = state.session;
if (!displayGdd || !session) {
throw new Error('fixture 应当带 GDD 和 session');
}
return planningV2ResultFor(session, displayGdd, 'approved', 'approved');
}
/** 策划中、尚未提交 GDD 的状态:没有待审批,也没有可展示的 GDD。 */
function planningV2WorkingResult(questionCount = 2) {
const questionResult = planningV2QuestionResult();
return {
...questionResult,
session: {
...questionResult.session,
status: 'planning',
questionCount,
currentQuestion: null,
},
result: null,
};
}
function planningV2QuestionResult() {
return {
session: {
schemaVersion: 'planning-session.v2',
engine: 'planning-session-v2',
sessionId: 'plan-session-v2-question',
projectId: 'local-project-draft',
mode: 'gdd',
status: 'awaiting_user',
turnIndex: 1,
questionCount: 1,
questionLimit: 8,
revisionCount: 0,
currentArtifactVersion: null,
currentQuestion: {
id: 'core_loop',
header: '当前要决定:核心循环',
question: '玩家在一局中主要反复做什么?',
options: [
{ label: '持续闪避', description: '保持移动并躲避来袭弹幕。' },
{ label: '规划路线', description: '观察弹幕并选择安全路线。' },
],
},
capabilities: { tools: [], skills: [] },
processingSeconds: 1.2,
createdAtUtc: '2026-09-03T00:00:00Z',
updatedAtUtc: '2026-09-03T00:00:01Z',
lastError: null,
},
result: {
schemaVersion: 'planning-turn-result.v2',
kind: 'question',
payload: {
question: {
id: 'core_loop',
header: '当前要决定:核心循环',
question: '玩家在一局中主要反复做什么?',
options: [
{ label: '持续闪避', description: '保持移动并躲避来袭弹幕。' },
{ label: '规划路线', description: '观察弹幕并选择安全路线。' },
],
},
},
},
currentArtifact: null,
replayed: false,
};
}
async function mountApprovalCard(
harness: ReturnType<typeof createProjectSupervisorRuntimeHarness>,
planningStartMode = false,
) {
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
mountFormalSupervisor(harness, planningStartMode);
return await screen.findByLabelText('GDD 审批卡');
}
async function mountPlanningSurface(
harness: ReturnType<typeof createProjectSupervisorRuntimeHarness>,
planningStartMode = false,
) {
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
mountFormalSupervisor(harness, planningStartMode);
return await screen.findByLabelText('立项策划阶段进度');
}
type PlanGddStateView = ReturnType<typeof createPlanGddStateView>;
/** 已批准:审批卡整张收掉,只剩标题栏和它下面的交付行。 */
function approvedPlanGddState() {
const base = createPlanGddStateView();
const gddRef = base.versions[0]!.gddRef;
return createPlanGddStateView({
state: 'approved',
pendingApproval: null,
approvedGddRef: gddRef,
versions: [
{
...base.versions[0]!,
status: 'approved',
decision: {
action: 'approve' as const,
decidedAtUtc: '2026-08-18T01:00:00Z',
},
},
],
session: base.session
? { ...base.session, phase: 'approved', awaitingAnswerFor: null }
: null,
});
}
/**
* 完整总控面板在策划链路下漏给用户的几块:面板本体、currentAction/计划进度、
* 五段式紧凑进度、子 Agent 列表。策划链路的断言统一从这里查。
*/
function expectSupervisorRuntimePanelAbsent() {
expect(screen.queryByLabelText('项目总控 Agent 状态')).toBeNull();
expect(screen.queryByLabelText('当前工作状态')).toBeNull();
expect(screen.queryByLabelText('项目总控 Agent 进度')).toBeNull();
expect(screen.queryByLabelText('专业 Agent 实时状态')).toBeNull();
expect(screen.queryByText(/专业 Agent 协作:/)).toBeNull();
}
function openReviseDialog() {
fireEvent.click(screen.getByRole('button', { name: '修改' }));
return screen.getByRole('dialog');
}
function typeComment(dialog: HTMLElement, text: string) {
const textarea = within(dialog).getByPlaceholderText('请输入原因');
fireEvent.change(textarea, { target: { value: text } });
return textarea as HTMLTextAreaElement;
}
export function registerPlanGddApprovalTests() {
it('ignores a late hydrate from the previously opened project', async () => {
const message = (text: string) => ({
role: 'assistant',
kind: 'assistant_text',
messageId: text,
atUtc: '2026-09-08T00:00:00Z',
text,
payload: { text },
});
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: {
...planningV2ApprovalResult(),
conversation: [message('当前项目的策划记录')],
},
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
window.history.pushState({}, '', '/?dev');
render(React.createElement(App, { planningStartMode: true }));
const openProject = async (path: string) => {
fireEvent.change(screen.getByLabelText('本地项目目录'), {
target: { value: path },
});
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
const pending = screen
.getByText(`创建 ${path}`)
.closest('.pending-command');
await act(async () => {
fireEvent.click(
within(pending as HTMLElement).getByRole('button', { name: '确认' }),
);
});
};
await openProject('/tmp/planning-a');
const originalInvoke = harness.invoke.getMockImplementation()!;
let finishOldHydrate!: (result: unknown) => void;
harness.invoke.mockImplementation(async (command, args) => {
if (
command === 'hydrate_planning_session_v2' &&
args?.projectPath === '/tmp/planning-a'
) {
return new Promise((resolve) => {
finishOldHydrate = resolve;
});
}
return originalInvoke(command, args);
});
fireEvent.focus(window);
await waitFor(() => expect(finishOldHydrate).toBeTypeOf('function'));
await openProject('/tmp/planning-b');
expect(screen.getByText('当前项目的策划记录')).not.toBeNull();
await act(async () => {
finishOldHydrate({
...planningV2ApprovalResult(),
conversation: [message('旧项目的迟到策划')],
});
});
expect(screen.queryByText('旧项目的迟到策划')).toBeNull();
expect(screen.getByText('当前项目的策划记录')).not.toBeNull();
});
it.each(['question', 'empty'] as const)(
'ignores a late %s hydrate after a newer hydrate has restored the GDD',
async (lateResult) => {
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: planningV2ApprovalResult(),
});
await mountApprovalCard(harness, true);
const originalInvoke = harness.invoke.getMockImplementation()!;
let finishOldHydrate!: (result: unknown) => void;
let deferNextHydrate = true;
harness.invoke.mockImplementation(async (command, args) => {
if (command === 'hydrate_planning_session_v2' && deferNextHydrate) {
deferNextHydrate = false;
return new Promise((resolve) => {
finishOldHydrate = resolve;
});
}
return originalInvoke(command, args);
});
fireEvent.focus(window);
await waitFor(() => expect(finishOldHydrate).toBeTypeOf('function'));
await act(async () => {
fireEvent.focus(window);
});
expect(screen.getByLabelText('GDD 审批卡')).not.toBeNull();
await act(async () => {
finishOldHydrate(
lateResult === 'question' ? planningV2QuestionResult() : null,
);
});
expect(screen.getByLabelText('GDD 审批卡')).not.toBeNull();
expect(screen.queryByText('玩家在一局中主要反复做什么?')).toBeNull();
},
);
it('re-hydrates authority after a failed GDD decision and keeps the failure visible', async () => {
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: planningV2ApprovalResult(),
});
await mountApprovalCard(harness);
const hydrateCallsBeforeDecision = harness.invoke.mock.calls.filter(
([command]) => command === 'hydrate_planning_session_v2',
).length;
harness.failNextPlanningV2Decision('PLAN_STALE_APPROVAL');
fireEvent.click(screen.getByRole('button', { name: '批准 v1' }));
// 失败分支必须重灌权威状态,否则卡片会停在已失效的 pending 身份上。
await waitFor(() => {
expect(
harness.invoke.mock.calls.filter(
([command]) => command === 'hydrate_planning_session_v2',
).length,
).toBeGreaterThan(hydrateCallsBeforeDecision);
});
// 而且重灌不能把决定失败的原因擦掉:hydrate 入口会 setPlanGddError(null)
// 两句顺序写反这条断言就红。
expect(screen.getByRole('alert').textContent).toContain(
'PLAN_STALE_APPROVAL',
);
});
it('reuses one decisionId while the decision is unchanged and mints a new one once the comment changes', async () => {
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: planningV2ApprovalResult(),
});
await mountApprovalCard(harness);
const dialog = openReviseDialog();
typeComment(dialog, '把核心循环压到三步');
await waitFor(() => {
expect(
within(dialog).getByRole('button', { name: '提交决定' }),
).toHaveProperty('disabled', false);
});
harness.failNextPlanningV2Decision('PLAN_DURABILITY_FAILED');
fireEvent.click(screen.getByRole('button', { name: '提交决定' }));
await waitFor(() => {
expect(harness.planningV2DecisionCalls).toHaveLength(1);
});
// 原样重试:属于方案 §13.2 的 busy/超时/网络重试,必须复用同一 decisionId。
harness.failNextPlanningV2Decision('PLAN_DURABILITY_FAILED');
await waitFor(() => {
expect(
within(dialog).getByRole('button', { name: '提交决定' }),
).toHaveProperty('disabled', false);
});
fireEvent.click(screen.getByRole('button', { name: '提交决定' }));
await waitFor(() => {
expect(harness.planningV2DecisionCalls).toHaveLength(2);
});
expect(harness.planningV2DecisionCalls[1].decisionId).toBe(
harness.planningV2DecisionCalls[0].decisionId,
);
// 改写修改意见:审批意图变了,必须换新的 decisionId,否则后端会以
// 「同 decisionId 的审批意图不一致」硬拒,用户改写后的原因永远落不了盘。
typeComment(dialog, '把核心循环压到两步,并去掉天气系统');
harness.failNextPlanningV2Decision('PLAN_DURABILITY_FAILED');
await waitFor(() => {
expect(
within(dialog).getByRole('button', { name: '提交决定' }),
).toHaveProperty('disabled', false);
});
fireEvent.click(screen.getByRole('button', { name: '提交决定' }));
await waitFor(() => {
expect(harness.planningV2DecisionCalls).toHaveLength(3);
});
expect(harness.planningV2DecisionCalls[2].decisionId).not.toBe(
harness.planningV2DecisionCalls[0].decisionId,
);
expect(harness.planningV2DecisionCalls[2].comment).toBe(
'把核心循环压到两步,并去掉天气系统',
);
});
it('labels the clarification round the user is actually on rather than the 0-indexed answered count', async () => {
// questionCount 是「已展示问题数」:等待第 3 轮回答时它恒为 3,当前轮次
// 由 currentQuestion 钉住。直接渲染成「轮次 2/8」会暗示还剩一轮。
const questionResult = planningV2QuestionResult();
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: {
...questionResult,
session: {
...questionResult.session,
questionCount: 3,
},
result: null,
},
});
await mountPlanningSurface(harness);
const progress = await screen.findByLabelText('立项策划阶段进度');
expect(within(progress).getByText('第 3 轮 / 共 8 轮')).not.toBeNull();
expect(within(progress).queryByText(/轮次 \d+\/\d+/)).toBeNull();
});
it('falls back to an answered-count label when no clarification answer is outstanding', async () => {
// currentQuestion 为 null(含恢复态)时不去猜当前是第几轮,只报已完成多少轮。
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: planningV2WorkingResult(2),
});
await mountPlanningSurface(harness);
const progress = await screen.findByLabelText('立项策划阶段进度');
expect(within(progress).getByText('已完成 2/8 轮澄清')).not.toBeNull();
});
it('paints the planning state once in the stage strip and keeps the fingerprint inside the GDD dialog', async () => {
// 阶段进度和审批卡曾各自带框叠在一起,「立项策划 / 待审批」在标题栏和卡头各画一遍,
// 卡头还露一截指纹。合成一个面之后:状态与版本只在标题栏,卡头只剩游戏标题和一句
// 话,指纹整条放进正文弹层做追溯。
const result = planningV2ApprovalResult();
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: result,
});
const card = await mountApprovalCard(harness);
const surface = screen.getByLabelText('立项策划');
const strip = within(surface).getByLabelText('立项策划阶段进度');
expect(within(strip).getByText('待审批')).not.toBeNull();
expect(within(strip).getByText('当前版本:v1')).not.toBeNull();
expect(surface.contains(card)).toBe(true);
expect(within(card).queryByText('立项策划')).toBeNull();
expect(within(card).queryByText('待审批')).toBeNull();
expect(within(card).queryByText(/^版本 v1$/)).toBeNull();
expect(within(card).queryByText(/指纹/)).toBeNull();
expect(within(card).getByRole('heading', { level: 2 }).textContent).toBe(
result.currentArtifact.payload.game.title,
);
fireEvent.click(
within(card).getByRole('button', { name: '查看 GDD 正文' }),
);
const dialog = screen.getByRole('dialog');
expect(within(dialog).getByLabelText('GDD 版本与指纹').textContent).toBe(
`Fast GDD v1 · ${result.currentArtifact.fingerprint}`,
);
});
it('hands the approved GDD back through the stage strip instead of a card', async () => {
// 批准之后审批卡按设计整张收掉,此前那一刻起用户就再也够不到 GDDapprovedGddRef
// 前端没人读,渲染好的 Markdown 也没有出口。交付行补的就是这个缺口——不额外占一
// 张卡,只在标题栏下多一行:文件在哪、看正文、用外部程序打开。
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: planningV2ApprovedResult(),
});
await mountPlanningSurface(harness);
expect(screen.queryByLabelText('GDD 审批卡')).toBeNull();
const delivery = await screen.findByLabelText('GDD 交付');
expect(delivery.textContent).toContain(
`${harness.projectPath}/game/fast_gdd.md`,
);
expect(screen.queryByRole('button', { name: /^批准/ })).toBeNull();
expect(screen.queryByRole('button', { name: '修改' })).toBeNull();
expect(screen.queryByRole('button', { name: '退回重做' })).toBeNull();
// 正文弹层与审批时是同一份,批准前后看到的内容一致。
fireEvent.click(
within(delivery).getByRole('button', { name: '查看 GDD 正文' }),
);
const dialog = screen.getByRole('dialog');
expect(within(dialog).getByLabelText('GDD 版本与指纹')).not.toBeNull();
});
it('asks the shell to open the rendered GDD for the current project', async () => {
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: planningV2ApprovedResult(),
});
await mountPlanningSurface(harness);
const delivery = await screen.findByLabelText('GDD 交付');
fireEvent.click(within(delivery).getByRole('button', { name: '打开文件' }));
await waitFor(() => {
expect(
harness.invoke.mock.calls.filter(
([command]) => command === 'open_local_project_plan_gdd_markdown',
),
).toHaveLength(1);
});
const [, args] = harness.invoke.mock.calls.find(
([command]) => command === 'open_local_project_plan_gdd_markdown',
)!;
// 路径交给后端自己在项目根下解析,前端只报项目——避免两侧各拼一次相对路径。
expect(args).toEqual({ projectPath: harness.projectPath });
});
it('offers to make an approved GDD into a game reference on the home entry', async () => {
const onMakeGame = vi.fn(async () => undefined);
render(
React.createElement(PlanGddStageProgress, {
state: approvedPlanGddState(),
active: true,
projectPath: '/tmp/approved-gdd-project',
onMakeGame,
}),
);
fireEvent.click(screen.getByRole('button', { name: '做成游戏' }));
await waitFor(() => {
expect(onMakeGame).toHaveBeenCalledTimes(1);
});
});
it('keeps the supervisor runtime panel off the planning lane while the planner is working', async () => {
// 完整面板是给做游戏链路的:十几个专业 Agent、多步计划、逐 Agent 重试。策划链路
// 只有一个策划会话,面板画出来的全是内部记账。状态由顶部的阶段进度承担;
// 底部在策划正常进行时什么都不该画。
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: planningV2WorkingResult(),
});
const progress = await mountPlanningSurface(harness);
expect(within(progress).getByText('策划中')).not.toBeNull();
expectSupervisorRuntimePanelAbsent();
expect(screen.queryByLabelText('立项策划运行状态')).toBeNull();
expect(screen.queryByText(/计划 \d+\/\d+/)).toBeNull();
});
it('keeps the chat composer but hides its placeholder in the planning lane', async () => {
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: planningV2WorkingResult(),
});
await mountPlanningSurface(harness);
expect(screen.getByRole('textbox', { name: '项目需求' })).not.toBeNull();
expect(
screen.queryByText('告诉策划 Agent 接下来要做什么,或输入 @ 选择资源'),
).toBeNull();
expect(
screen.queryByText('告诉项目总控接下来要做什么,或输入 @ 选择资源'),
).toBeNull();
});
it('still surfaces the clarification card on the planning lane', async () => {
// 澄清卡是 V2 策划链路唯一需要用户动手的交互面之一。
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: planningV2QuestionResult(),
});
await mountPlanningSurface(harness, true);
const strip = await screen.findByLabelText('立项策划运行状态');
expect(within(strip).getByLabelText('Needs input')).not.toBeNull();
expect(
within(strip).getByText('玩家在一局中主要反复做什么?'),
).not.toBeNull();
expectSupervisorRuntimePanelAbsent();
expect(screen.queryByText(/agent\.delegate/)).toBeNull();
});
it('surfaces a V2 provider failure on the planning lane', async () => {
// V2 provider_failed 由同一策划会话承接,错误显示在窄条中,用户可用输入框重新提交。
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: {
...planningV2QuestionResult(),
session: {
...planningV2QuestionResult().session,
status: 'provider_failed',
currentQuestion: null,
lastError: {
code: 'PROVIDER_FAILED',
summary: 'Planning V2 Provider 调用失败',
},
},
result: {
schemaVersion: 'planning-turn-result.v2',
kind: 'error',
payload: {
code: 'PROVIDER_FAILED',
summary: 'Planning V2 Provider 调用失败',
},
},
},
});
await mountPlanningSurface(harness, true);
const strip = await screen.findByLabelText('立项策划运行状态');
expect(within(strip).getByRole('alert').textContent).toContain('执行失败');
expectSupervisorRuntimePanelAbsent();
expect(screen.queryByText(/project-planning/)).toBeNull();
});
it('keeps a stylesheet rule for every class the planning components reference', () => {
// #193「Codex/agent chat layout fix」重写聊天样式时,把策划前端的选择器整段
// 误删:组件(TSX)原样保留、样式全数消失,审批卡/阶段条/交付行裸奔,正文弹层
// 失去 fixed 定位变成内联平铺。组件和它的样式分居两个文件,重构样式的人看不见
// 使用方——这条测试就是那根缺失的连线:类名清单直接从组件源码里推导,组件加了
// 新类而样式没跟上、或样式又被顺手清掉,这里都会红。
const componentSources = [
'src/features/project-workspace/GddApprovalCard.tsx',
'src/features/project-workspace/PlanningLaneRuntimeStrip.tsx',
]
.map((path) =>
readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell', path),
'utf8',
),
)
.join('\n');
const referencedClasses = new Set<string>();
for (const match of componentSources.matchAll(
/className=(?:"([^"]+)"|\{`([^`]+)`\})/g,
)) {
// 条件类长在插值里:`plan-gdd-surface${showCard ? ' …--with-card' : ''}`。
// 把 `${…}` 整段丢掉等于把它们排除在守门之外,而它们恰恰是最容易被顺手删干净
// 的一档——`--with-card` 挂着审批卡的行模板,没有它卡片底部会被外壳的
// `overflow: hidden` 切掉。只取插值里的字符串字面量:三元的条件、变量名都不是
// 类名,不能混进清单。
const literal = (match[1] ?? match[2] ?? '').replace(
/\$\{([^}]*)\}/g,
(_whole, expression: string) =>
[...expression.matchAll(/'([^']*)'|"([^"]*)"/g)]
.map((piece) => piece[1] ?? piece[2] ?? '')
.join(' '),
);
for (const name of literal.split(/\s+/)) {
if (name) {
referencedClasses.add(name);
}
}
}
expect(referencedClasses.size).toBeGreaterThan(10);
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
for (const name of referencedClasses) {
// 子串匹配会把 `.gdd-approval-card__header` 当成 `.gdd-approval-card` 的证据:
// 前缀类的规则被删光、只剩派生类时这里照样绿。要求类名后面不能再跟类名字符,
// 才是「存在这个类的精确 selector」。
const exactSelector = new RegExp(
`\\.${name.replace(/[^\w-]/g, '\\$&')}(?![\\w-])`,
);
expect(
exactSelector.test(styles),
`styles.css 缺少 .${name} 的精确 selector`,
).toBe(true);
}
// 正文弹层必须是浮层:backdrop 一旦丢掉 fixed 定位,整个 GDD 会内联平铺进
// 消息流里——这正是误删当时最刺眼的症状。
expect(styles).toMatch(
/\.gdd-approval-card__dialog-backdrop\s*\{[^}]*position:\s*fixed/s,
);
// 做方案的单栏工作台不在上面两个组件文件里(类名由
// view/project-development/index.tsx 拼出),显式钉住:没有这两条规则时,
// 策划项目一打开就是左边一整片空资源画布。它们和策划样式死在 #193 同一刀里。
expect(styles).toMatch(
/\.game-workbench-layout\.is-conversation-only\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/s,
);
expect(styles).toMatch(
/\.game-workbench-layout\.is-conversation-only \.game-workbench-stage\s*\{[^}]*display:\s*none/s,
);
// 工作台里的会话列是 `overflow: hidden` 的定高列。策划链路比另外两条多出窄条
// (澄清卡 / 失败恢复),只有把这一列排成 flex 列、窄条不参与压缩,它才落在可视
// 区里;否则组件照常渲染却被静默裁到线外,屏幕上什么都没有。命中判据必须包含窄条
// 自身——只认 `.plan-gdd-surface` 时,`planGddState` 还没 hydrate 出来的那一格里
// 澄清卡照样被裁。
expect(styles).toMatch(
/\.game-workbench-chat\s+\.project-supervisor-conversation:has\([^)]*\.planning-lane-runtime-strip[^)]*\)\s*\{[^}]*display:\s*flex/s,
);
// `--with-card` 有两条规则,上面的存在性检查只要还剩一条就绿。承重的是这一条:
// 策划面是 `display: grid` + `overflow: hidden` 的外壳,审批卡的 `max-height`
// 和内滚要靠这个行模板才有边界。只删它、留下那条分隔线,表现是批准后的交付行
// 连同路径和两个按钮被切在壳外。
expect(styles).toMatch(
/\.game-workbench-chat\s+\.plan-gdd-surface--with-card\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\)/s,
);
});
it('routes a formal planning entry through Runtime V2 commands', async () => {
const harness = createProjectSupervisorRuntimeHarness({
planningV2Result: planningV2ApprovalResult(),
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
window.history.pushState({}, '', '/');
render(
React.createElement(App, {
initialProjectPath: harness.projectPath,
orchestrationMode: 'single-supervisor',
planningStartMode: true,
projectSupervisorOnly: true,
}),
);
await screen.findByLabelText('GDD 审批卡');
const planningHydrateCall = harness.invoke.mock.calls.find(
([command]) => command === 'hydrate_planning_session_v2',
);
expect(planningHydrateCall).toBeDefined();
fireEvent.click(screen.getByRole('button', { name: '批准 v1' }));
await waitFor(() => {
expect(
harness.invoke.mock.calls.some(
([command]) => command === 'decide_planning_artifact_v2',
),
).toBe(true);
});
});
it('starts a new planning entry through the design agent', async () => {
const harness = createProjectSupervisorRuntimeHarness({
designAgentContinueView: {
session: {
sessionId: 'design-session-1',
projectId: 'local-project-draft',
currentPhase: 'concept',
approvedPhases: [],
pendingApproval: null,
pendingClarification: {
requestId: 'clarify-1',
question: '玩家在一局中主要反复做什么?',
options: ['持续闪避', '站桩输出'],
createdAt: 1,
},
turnIndex: 1,
lastError: null,
},
messages: [
{
id: 'u1',
role: 'user',
text: '做一个2D弹幕射击游戏',
},
{
id: 'a1',
role: 'assistant',
text: '先确认核心循环。',
},
],
running: false,
canRetry: false,
},
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
window.history.pushState({}, '', '/');
render(
React.createElement(App, {
initialProjectPath: harness.projectPath,
initialSupervisorMessage: '做一个2D弹幕射击游戏',
orchestrationMode: 'single-supervisor',
planningStartMode: true,
projectSupervisorOnly: true,
}),
);
await screen.findByText('玩家在一局中主要反复做什么?');
expect(
harness.invoke.mock.calls.some(
([command]) => command === 'hydrate_design_agent_session',
),
).toBe(true);
expect(
harness.invoke.mock.calls.some(
([command]) => command === 'start_planning_session_v2',
),
).toBe(false);
expect(screen.queryByText(/agent\.delegate/)).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '持续闪避' }));
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'continue_design_agent_session',
expect.objectContaining({
input: expect.objectContaining({
type: 'clarification',
requestId: 'clarify-1',
optionIndex: 0,
}),
}),
);
});
});
}