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
策划模式保留输入框但隐藏占位文案 补充策划模式输入框显示回归测试
839 lines
31 KiB
TypeScript
839 lines
31 KiB
TypeScript
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 () => {
|
||
// 批准之后审批卡按设计整张收掉,此前那一刻起用户就再也够不到 GDD:approvedGddRef
|
||
// 前端没人读,渲染好的 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,
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
}
|