merge: integrate Canvas plan and reference flow
# Conflicts: # README.md
This commit is contained in:
@@ -22,12 +22,25 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/lib/image-workspace')>();
|
||||
return {
|
||||
...original,
|
||||
resolveImageWorkspaceAssetUrl: vi.fn().mockResolvedValue('http://127.0.0.1/asset'),
|
||||
resolveImageWorkspaceAssetUrl: vi.fn().mockReturnValue(new Promise(() => undefined)),
|
||||
saveImageWorkspaceAsset: vi.fn().mockResolvedValue({ status: 'saved' }),
|
||||
uploadImageWorkspaceAsset: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function setViewport(desktop: boolean) {
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: desktop,
|
||||
media: '(min-width: 1024px)',
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
function prepareWorkspace(overrides = {}) {
|
||||
const workspace = designWorkspaceFixture(overrides);
|
||||
const actions = {
|
||||
@@ -35,6 +48,10 @@ function prepareWorkspace(overrides = {}) {
|
||||
connectEvents: vi.fn(),
|
||||
disconnectEvents: vi.fn(),
|
||||
refreshWorkspace: vi.fn().mockResolvedValue(workspace),
|
||||
createProject: vi.fn().mockResolvedValue(workspace),
|
||||
renameProject: vi.fn().mockResolvedValue(workspace),
|
||||
deleteProject: vi.fn().mockResolvedValue({ workspaceId: 'workspace-1', deleted: true }),
|
||||
selectProject: vi.fn().mockResolvedValue(undefined),
|
||||
applyFieldOperations: vi.fn().mockResolvedValue(workspace),
|
||||
sendChat: vi.fn().mockResolvedValue(workspace),
|
||||
resolveDecisionPrompt: vi.fn().mockResolvedValue(workspace),
|
||||
@@ -60,13 +77,10 @@ function prepareWorkspace(overrides = {}) {
|
||||
return { workspace, actions };
|
||||
}
|
||||
|
||||
function openMobileCreationCard() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^当前想法与作品/ }));
|
||||
}
|
||||
|
||||
describe('youth AI Design Canvas page', () => {
|
||||
describe('AI Design Canvas page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setViewport(false);
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
accessToken: 'token',
|
||||
@@ -76,23 +90,65 @@ describe('youth AI Design Canvas page', () => {
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
});
|
||||
|
||||
it('starts with a guided conversation and keeps the structured summary secondary', () => {
|
||||
it('keeps the loading shell until the first Workspace bootstrap settles', () => {
|
||||
const load = vi.fn();
|
||||
useImageWorkspaceStore.setState({ status: 'idle', workspace: null, bootstrap: null, load });
|
||||
|
||||
render(<ImageCanvas />);
|
||||
|
||||
expect(screen.getByLabelText('正在加载 AI 设计')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('image-workspace-empty-state')).not.toBeInTheDocument();
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps conversation and the active editable plan together in the center', () => {
|
||||
prepareWorkspace();
|
||||
render(<ImageCanvas />);
|
||||
|
||||
expect(screen.getByTestId('image-workspace-conversation')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '和 AI 聊聊你的想法' })).toBeInTheDocument();
|
||||
expect(screen.getByText('像和同学讲故事一样说就好。AI 会先听懂,再一次问一个最重要的问题。')).toBeInTheDocument();
|
||||
openMobileCreationCard();
|
||||
const card = screen.getByTestId('youth-creation-card');
|
||||
expect(card).toBeInTheDocument();
|
||||
expect(within(card).getByRole('heading', { name: 'AI 听懂的想法' })).toBeInTheDocument();
|
||||
expect(screen.getByText('做一张便携咖啡机的小红书主视觉')).toBeInTheDocument();
|
||||
const conversation = screen.getByTestId('image-workspace-conversation');
|
||||
expect(within(conversation).getByRole('heading', { name: '和 AI 一起完善创作' })).toBeInTheDocument();
|
||||
expect(within(conversation).getByRole('heading', { name: '制作方案' })).toBeInTheDocument();
|
||||
expect(within(conversation).getByRole('textbox', { name: '创作提示词' })).toHaveValue(
|
||||
'把便携咖啡机呈现为城市通勤中的精密工具',
|
||||
);
|
||||
expect(within(conversation).getByRole('combobox', { name: '画幅' })).toHaveValue('3:4');
|
||||
expect(screen.queryByText('Living Form')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('目标与用途')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Workspace list as a full-height right Works rail on desktop', () => {
|
||||
setViewport(true);
|
||||
prepareWorkspace();
|
||||
render(<ImageCanvas />);
|
||||
|
||||
const rail = screen.getByTestId('image-workspace-works-rail');
|
||||
expect(rail).toBeInTheDocument();
|
||||
expect(within(rail).getByRole('heading', { name: '我的作品' })).toBeInTheDocument();
|
||||
expect(within(rail).getByRole('button', { name: '打开作品 咖啡机新品主视觉' })).toBeInTheDocument();
|
||||
expect(within(rail).getByRole('button', { name: '新建作品' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '作品' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prioritizes an offered current plan over older completed-work status', () => {
|
||||
setViewport(true);
|
||||
prepareWorkspace({
|
||||
form: designFormFixture({ activeQuotes: [designQuoteFixture()] }),
|
||||
});
|
||||
render(<ImageCanvas />);
|
||||
|
||||
expect(screen.getByText(/制作方案已准备好,确认前仍可修改/)).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('image-workspace-works-rail')).getByText('待确认')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the Works rail as a right sheet on compact layouts', () => {
|
||||
prepareWorkspace();
|
||||
render(<ImageCanvas />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '作品' }));
|
||||
expect(screen.getByTestId('image-workspace-works-rail')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('image-workspace-works-rail')).getByRole('heading', { name: '我的作品' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows one unfinished assistant reply incrementally and replaces it with the canonical turn', async () => {
|
||||
const { workspace } = prepareWorkspace();
|
||||
const firstChunk = '收到,我们要制作';
|
||||
@@ -120,26 +176,15 @@ describe('youth AI Design Canvas page', () => {
|
||||
});
|
||||
|
||||
render(<ImageCanvas />);
|
||||
|
||||
const conversation = screen.getByTestId('image-workspace-conversation');
|
||||
const pendingMessage = within(conversation).getByTestId('pending-design-chat-operation-chat-1');
|
||||
expect(within(pendingMessage).getByText('做一张社团活动海报')).toBeInTheDocument();
|
||||
expect(within(pendingMessage).getByText('发送中')).toBeInTheDocument();
|
||||
expect(within(conversation).queryByRole('button', { name: '下一步:看看制作方案' }))
|
||||
.not.toBeInTheDocument();
|
||||
expect(within(conversation).queryByTestId('design-assistant-progress')).not.toBeInTheDocument();
|
||||
expect(within(conversation).queryByText('AI 正在整理你的想法')).not.toBeInTheDocument();
|
||||
expect(within(conversation).getByText('我已经整理了用途、受众和初步概念,请确认右侧建议。')).toBeInTheDocument();
|
||||
const streamingReply = within(conversation)
|
||||
.getByTestId('streaming-design-assistant-operation-chat-1');
|
||||
expect(streamingReply).toHaveTextContent(firstChunk);
|
||||
expect(within(conversation).getByTestId('pending-design-chat-operation-chat-1')).toHaveTextContent('发送中');
|
||||
expect(within(conversation).getByTestId('streaming-design-assistant-operation-chat-1')).toHaveTextContent(firstChunk);
|
||||
expect(within(conversation).queryByTestId('youth-creation-card')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useImageWorkspaceStore.setState({
|
||||
assistantStreams: { 'operation-chat-1': unfinishedDraft },
|
||||
});
|
||||
useImageWorkspaceStore.setState({ assistantStreams: { 'operation-chat-1': unfinishedDraft } });
|
||||
});
|
||||
expect(streamingReply).toHaveTextContent(unfinishedDraft);
|
||||
expect(within(conversation).getByTestId('streaming-design-assistant-operation-chat-1')).toHaveTextContent(unfinishedDraft);
|
||||
|
||||
act(() => {
|
||||
useImageWorkspaceStore.setState({
|
||||
@@ -157,15 +202,13 @@ describe('youth AI Design Canvas page', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(conversation).queryByTestId('streaming-design-assistant-operation-chat-1'))
|
||||
.not.toBeInTheDocument();
|
||||
expect(within(conversation).queryByTestId('streaming-design-assistant-operation-chat-1')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(within(conversation).getAllByText(unfinishedDraft)).toHaveLength(1);
|
||||
expect(within(conversation).getByRole('button', { name: '下一步:看看制作方案' }))
|
||||
.toBeInTheDocument();
|
||||
expect(within(conversation).getByTestId('youth-creation-card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the mobile quote confirmation from the ready conversation without starting a paid task', async () => {
|
||||
it('requests a Quote from the inline plan without starting a paid task', async () => {
|
||||
const { actions } = prepareWorkspace();
|
||||
const quotedWorkspace = designWorkspaceFixture({
|
||||
form: designFormFixture({ activeQuotes: [designQuoteFixture()] }),
|
||||
@@ -176,15 +219,14 @@ describe('youth AI Design Canvas page', () => {
|
||||
});
|
||||
render(<ImageCanvas />);
|
||||
|
||||
const conversation = screen.getByTestId('image-workspace-conversation');
|
||||
fireEvent.click(within(conversation).getByRole('button', { name: '下一步:看看制作方案' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '准备制作方案' }));
|
||||
|
||||
expect(actions.requestQuote).toHaveBeenCalledOnce();
|
||||
expect(actions.confirmGeneration).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: '制作方案准备好了' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '确认并开始制作' })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('design-offered-quote')).toBeInTheDocument();
|
||||
expect(screen.getByText(/预计使用/)).toHaveTextContent('12');
|
||||
});
|
||||
|
||||
it('shows a submitted message immediately and replaces it with the canonical turn', async () => {
|
||||
@@ -211,32 +253,11 @@ describe('youth AI Design Canvas page', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<ImageCanvas />);
|
||||
|
||||
const conversation = screen.getByTestId('image-workspace-conversation');
|
||||
const pendingMessage = within(conversation).getByTestId('pending-design-chat-operation-chat-1');
|
||||
expect(within(pendingMessage).getByText(message)).toBeInTheDocument();
|
||||
expect(within(pendingMessage).getByText('发送中')).toBeInTheDocument();
|
||||
expect(within(conversation).queryByRole('heading', { name: '先把脑中的画面说出来' }))
|
||||
.not.toBeInTheDocument();
|
||||
expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' }))
|
||||
.toHaveValue('');
|
||||
|
||||
act(() => {
|
||||
useImageWorkspaceStore.setState((state) => ({
|
||||
pendingOperations: {
|
||||
...state.pendingOperations,
|
||||
'operation-chat-1': {
|
||||
...state.pendingOperations['operation-chat-1']!,
|
||||
status: 'unknown',
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
expect(within(pendingMessage).getByText('正在确认')).toBeInTheDocument();
|
||||
expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' }))
|
||||
.toHaveValue('');
|
||||
expect(within(conversation).getByTestId('pending-design-chat-operation-chat-1')).toHaveTextContent(message);
|
||||
expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toHaveValue('');
|
||||
|
||||
act(() => {
|
||||
useImageWorkspaceStore.setState({
|
||||
@@ -254,13 +275,8 @@ describe('youth AI Design Canvas page', () => {
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(conversation).getAllByText(message)).toHaveLength(1);
|
||||
});
|
||||
expect(within(conversation).queryByTestId('pending-design-chat-operation-chat-1'))
|
||||
.not.toBeInTheDocument();
|
||||
expect(within(conversation).queryByText('发送中')).not.toBeInTheDocument();
|
||||
expect(within(conversation).getByText(/更像漫画,还是更像电影/)).toBeInTheDocument();
|
||||
await waitFor(() => expect(within(conversation).getAllByText(message)).toHaveLength(1));
|
||||
expect(within(conversation).queryByTestId('pending-design-chat-operation-chat-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invites a free description and does not turn a legacy decision prompt into a form', () => {
|
||||
@@ -273,10 +289,7 @@ describe('youth AI Design Canvas page', () => {
|
||||
targetPaths: ['visual.art_direction'],
|
||||
title: '画面想要什么感觉?',
|
||||
body: '请从下面选一个。',
|
||||
options: [
|
||||
{ id: 'cartoon', label: '有趣的卡通', description: null },
|
||||
{ id: 'cinema', label: '像电影一样', description: null },
|
||||
],
|
||||
options: [{ id: 'cartoon', label: '有趣的卡通', description: null }],
|
||||
}],
|
||||
}),
|
||||
turns: [],
|
||||
@@ -286,34 +299,28 @@ describe('youth AI Design Canvas page', () => {
|
||||
|
||||
const conversation = screen.getByTestId('image-workspace-conversation');
|
||||
expect(within(conversation).getByRole('heading', { name: '先把脑中的画面说出来' })).toBeInTheDocument();
|
||||
expect(within(conversation).getByText(/想画什么、发生在哪里/)).toBeInTheDocument();
|
||||
expect(within(conversation).queryByText('画面想要什么感觉?')).not.toBeInTheDocument();
|
||||
expect(within(conversation).queryByRole('button', { name: '有趣的卡通' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(conversation).getByRole('button', { name: '我想做一张热闹的社团招新海报' }));
|
||||
expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toHaveValue(
|
||||
'我想做一张热闹的社团招新海报',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends high-impact fine adjustments through the existing typed field operations', () => {
|
||||
it('sends compact production edits through the existing typed field operations', async () => {
|
||||
const { actions } = prepareWorkspace();
|
||||
render(<ImageCanvas />);
|
||||
|
||||
openMobileCreationCard();
|
||||
fireEvent.click(screen.getByRole('button', { name: '手动调整(可选)' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '电脑横屏' }));
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '画幅' }), { target: { value: '16:9' } });
|
||||
|
||||
expect(actions.applyFieldOperations).toHaveBeenCalledWith(
|
||||
[
|
||||
await waitFor(() => {
|
||||
expect(actions.applyFieldOperations).toHaveBeenCalledWith([
|
||||
{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' },
|
||||
{ kind: 'set', path: 'output.orientation', value: 'landscape' },
|
||||
],
|
||||
[],
|
||||
);
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a plain-language production confirmation and confirms only the Quote identity', () => {
|
||||
it('confirms only the offered Quote identity and keeps provider details private', () => {
|
||||
const quote = designQuoteFixture();
|
||||
quote.warnings = [{
|
||||
code: 'reference_observation_fallback',
|
||||
@@ -321,20 +328,14 @@ describe('youth AI Design Canvas page', () => {
|
||||
severity: 'warning',
|
||||
path: 'references',
|
||||
}];
|
||||
const workspace = designWorkspaceFixture({
|
||||
const { actions } = prepareWorkspace({
|
||||
form: designFormFixture({ activeQuotes: [quote] }),
|
||||
});
|
||||
const { actions } = prepareWorkspace(workspace);
|
||||
render(<ImageCanvas />);
|
||||
|
||||
openMobileCreationCard();
|
||||
expect(screen.getByRole('heading', { name: '制作方案准备好了' })).toBeInTheDocument();
|
||||
expect(screen.getByText('12')).toBeInTheDocument();
|
||||
expect(screen.getByText('有张参考图还没检查完成,AI 会谨慎使用它。')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/frozen compiler/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue(/prompt/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/规格版本|报价已冻结|生成方式|不可变/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '花 12 设计点开始制作' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认并开始制作' }));
|
||||
expect(actions.confirmGeneration).toHaveBeenCalledWith('quote-1');
|
||||
});
|
||||
|
||||
@@ -368,10 +369,9 @@ describe('youth AI Design Canvas page', () => {
|
||||
'AI 这次没听懂。你的内容还在输入框里,可以补充一个画面细节后再发送',
|
||||
);
|
||||
});
|
||||
expect(toastErrorMock).not.toHaveBeenCalledWith('消息没有发出去,请再试一次');
|
||||
});
|
||||
|
||||
it('explains an unknown accepted result without suggesting a second production', () => {
|
||||
it('keeps an unknown accepted operation recoverable without offering a duplicate action', () => {
|
||||
const { actions } = prepareWorkspace();
|
||||
useImageWorkspaceStore.setState({
|
||||
pendingOperations: {
|
||||
@@ -395,18 +395,14 @@ describe('youth AI Design Canvas page', () => {
|
||||
});
|
||||
render(<ImageCanvas />);
|
||||
|
||||
expect(within(screen.getByTestId('image-workspace-conversation'))
|
||||
.getByRole('button', { name: '正在准备方案' })).toBeDisabled();
|
||||
openMobileCreationCard();
|
||||
expect(screen.getByText('还在确认刚才的操作')).toBeInTheDocument();
|
||||
expect(screen.getByText(/不会重复制作或重复扣费/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '看看制作方案', exact: true })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '准备制作方案' })).toBeDisabled();
|
||||
expect(actions.requestQuote).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看原来的结果' }));
|
||||
expect(actions.retryOperation).toHaveBeenCalledWith('operation-1');
|
||||
});
|
||||
|
||||
it('opens project creation when the account has no Design Workspace', () => {
|
||||
it('opens project creation from an empty Canvas', async () => {
|
||||
useImageWorkspaceStore.setState({
|
||||
status: 'ready',
|
||||
bootstrap: { ...designBootstrapFixture(), workspaces: [] },
|
||||
@@ -414,13 +410,11 @@ describe('youth AI Design Canvas page', () => {
|
||||
workspace: null,
|
||||
error: null,
|
||||
load: vi.fn(),
|
||||
createProject: vi.fn(),
|
||||
});
|
||||
const listener = vi.fn();
|
||||
window.addEventListener('niancode:image-workspace:create-project', listener);
|
||||
render(<ImageCanvas />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始第一个创作' }));
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
window.removeEventListener('niancode:image-workspace:create-project', listener);
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始第一个作品' }));
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: '新建作品' })).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,11 +12,12 @@ vi.mock('@/components/layout/Sidebar', () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/layout/TitleBar', () => ({
|
||||
TitleBar: ({ integrated = false, overlay = false, pageTitle, sidebarCollapsedOverride }: { integrated?: boolean; overlay?: boolean; pageTitle?: string; sidebarCollapsedOverride?: boolean }) => (
|
||||
TitleBar: ({ integrated = false, overlay = false, showSidebarControls = true, pageTitle, sidebarCollapsedOverride }: { integrated?: boolean; overlay?: boolean; showSidebarControls?: boolean; pageTitle?: string; sidebarCollapsedOverride?: boolean }) => (
|
||||
<header
|
||||
data-testid="titlebar-stub"
|
||||
data-integrated={integrated ? 'true' : 'false'}
|
||||
data-overlay={overlay ? 'true' : 'false'}
|
||||
data-sidebar-controls={showSidebarControls ? 'true' : 'false'}
|
||||
data-page-title={pageTitle ?? ''}
|
||||
data-sidebar-collapsed={sidebarCollapsedOverride === undefined ? 'unset' : String(sidebarCollapsedOverride)}
|
||||
/>
|
||||
@@ -59,6 +60,8 @@ describe('MainLayout module isolation', () => {
|
||||
expect(screen.getByTestId('main-content')).toHaveClass('basis-0', 'h-full', 'overflow-hidden', 'p-0');
|
||||
expect(screen.getByTestId('main-content')).not.toHaveClass('p-5', 'sm:p-6');
|
||||
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'true');
|
||||
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-sidebar-controls', 'false');
|
||||
expect(screen.queryByTestId('sidebar-stub')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the user sidebar preference while moving the inspiration title into the title bar', () => {
|
||||
|
||||
39
tests/unit/reference-tokens.test.ts
Normal file
39
tests/unit/reference-tokens.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
findUnboundReferenceNumbers,
|
||||
findReferenceMentionRange,
|
||||
insertReferenceToken,
|
||||
promptReferencesImage,
|
||||
removeReferenceTokenAndShift,
|
||||
} from '@/pages/ImageCanvas/reference-tokens';
|
||||
|
||||
describe('Canvas reference tokens', () => {
|
||||
it('recognizes explicit numbered image aliases only', () => {
|
||||
expect(promptReferencesImage('参考 @图片1 和 @图2', 0)).toBe(true);
|
||||
expect(promptReferencesImage('参考 @图片1 和 @图2', 1)).toBe(true);
|
||||
expect(promptReferencesImage('普通的 @图片 文字', 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports aliases that do not have a bound reference', () => {
|
||||
expect(findUnboundReferenceNumbers('参考 @图片1、@图3 和 @图片2,再看 @图片3', 1))
|
||||
.toEqual([2, 3]);
|
||||
expect(findUnboundReferenceNumbers('不要使用 @图片0', 2)).toEqual([0]);
|
||||
});
|
||||
|
||||
it('finds an unfinished @ mention at the cursor', () => {
|
||||
expect(findReferenceMentionRange('让 @图', 4)).toEqual({ start: 2, end: 4 });
|
||||
expect(findReferenceMentionRange('让 @图 继续', 7)).toBeNull();
|
||||
});
|
||||
|
||||
it('inserts a token at the mention range', () => {
|
||||
expect(insertReferenceToken('让 @ 作为主体', 1, { start: 2, end: 3 })).toEqual({
|
||||
value: '让 @图片2 作为主体',
|
||||
cursor: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('removes the deleted token and shifts later aliases with their bindings', () => {
|
||||
expect(removeReferenceTokenAndShift('参考 @图片1,结合 @图片2 的光线', 0))
|
||||
.toBe('参考 结合 @图片1 的光线');
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,17 @@ describe('TitleBar platform behavior', () => {
|
||||
expect(screen.queryByTestId('titlebar-project-context')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps only the product chrome when a workspace owns its navigation', () => {
|
||||
window.electron.platform = 'darwin';
|
||||
|
||||
render(<TitleBar integrated overlay showSidebarControls={false} />);
|
||||
|
||||
expect(screen.queryByTestId('titlebar-sidebar-surface')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('titlebar-sidebar-toggle')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('titlebar-main-surface')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('titlebar-logo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the painting page title after the macOS traffic-light safe area', () => {
|
||||
window.electron.platform = 'darwin';
|
||||
useSettingsStore.setState({ sidebarCollapsed: true });
|
||||
|
||||
@@ -1,64 +1,29 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DesignPlanHistory } from '@/pages/ImageCanvas/DesignPlanHistory';
|
||||
import { YouthCreationCard } from '@/pages/ImageCanvas/YouthCreationCard';
|
||||
import { DesignTaskPanel } from '@/pages/ImageCanvas/DesignProductionPanel';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type {
|
||||
DesignCompilationIssue,
|
||||
DesignGenerationTask,
|
||||
} from '../../shared/image-workspace';
|
||||
import type { DesignGenerationTask } from '../../shared/image-workspace';
|
||||
import {
|
||||
designFormFixture,
|
||||
designQuoteFixture,
|
||||
designValuesFixture,
|
||||
designFormFixture,
|
||||
designWorkspaceFixture,
|
||||
} from '../fixtures/design-workspace-v2';
|
||||
|
||||
function renderCard(
|
||||
workspace = designWorkspaceFixture({ form: designFormFixture() }),
|
||||
options: {
|
||||
generationAvailable?: boolean;
|
||||
quoteBlockers?: DesignCompilationIssue[];
|
||||
onOpenFineTune?: () => void;
|
||||
onFocusComposer?: () => void;
|
||||
onQuoteOffered?: () => void;
|
||||
} = {},
|
||||
) {
|
||||
const requestQuote = vi.fn().mockResolvedValue(workspace);
|
||||
const resolveDecisionPrompt = vi.fn().mockResolvedValue(workspace);
|
||||
const onOpenFineTune = options.onOpenFineTune ?? vi.fn();
|
||||
const onFocusComposer = options.onFocusComposer ?? vi.fn();
|
||||
const onQuoteOffered = options.onQuoteOffered ?? vi.fn();
|
||||
useImageWorkspaceStore.setState({
|
||||
workspace,
|
||||
quoteBlockers: [],
|
||||
pendingOperations: {},
|
||||
requestQuote,
|
||||
resolveDecisionPrompt,
|
||||
});
|
||||
render(
|
||||
<YouthCreationCard
|
||||
workspace={workspace}
|
||||
quoteBlockers={options.quoteBlockers ?? []}
|
||||
generationAvailable={options.generationAvailable ?? true}
|
||||
onOpenFineTune={onOpenFineTune}
|
||||
onFocusComposer={onFocusComposer}
|
||||
onQuoteOffered={onQuoteOffered}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
workspace,
|
||||
requestQuote,
|
||||
resolveDecisionPrompt,
|
||||
onOpenFineTune,
|
||||
onFocusComposer,
|
||||
onQuoteOffered,
|
||||
};
|
||||
}
|
||||
const { uploadMock } = vi.hoisted(() => ({ uploadMock: vi.fn() }));
|
||||
|
||||
function taskFixture(
|
||||
overrides: Partial<DesignGenerationTask> = {},
|
||||
): DesignGenerationTask {
|
||||
vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/lib/image-workspace')>();
|
||||
return {
|
||||
...original,
|
||||
resolveImageWorkspaceAssetUrl: vi.fn().mockReturnValue(new Promise(() => undefined)),
|
||||
saveImageWorkspaceAsset: vi.fn().mockResolvedValue({ status: 'saved' }),
|
||||
uploadImageWorkspaceAsset: uploadMock,
|
||||
};
|
||||
});
|
||||
|
||||
function taskFixture(overrides: Partial<DesignGenerationTask> = {}): DesignGenerationTask {
|
||||
const quote = designQuoteFixture();
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
@@ -81,11 +46,7 @@ function taskFixture(
|
||||
},
|
||||
customerHoldExpiresAt: quote.expiresAt,
|
||||
resolutionDeadlineAt: null,
|
||||
progress: {
|
||||
stage: 'generating',
|
||||
completedRequiredSteps: 0,
|
||||
totalRequiredSteps: 1,
|
||||
},
|
||||
progress: { stage: 'generating', completedRequiredSteps: 0, totalRequiredSteps: 1 },
|
||||
executionHealth: 'normal',
|
||||
cancellation: { state: 'available', outcome: null },
|
||||
issues: [],
|
||||
@@ -96,247 +57,262 @@ function taskFixture(
|
||||
};
|
||||
}
|
||||
|
||||
function renderCard(workspace = designWorkspaceFixture()) {
|
||||
const actions = {
|
||||
applyFieldOperations: vi.fn().mockResolvedValue(workspace),
|
||||
requestQuote: vi.fn().mockResolvedValue(workspace),
|
||||
confirmGeneration: vi.fn().mockResolvedValue(workspace),
|
||||
};
|
||||
useImageWorkspaceStore.setState({
|
||||
workspace,
|
||||
quoteBlockers: [],
|
||||
pendingOperations: {},
|
||||
...actions,
|
||||
});
|
||||
render(
|
||||
<YouthCreationCard
|
||||
workspace={workspace}
|
||||
quoteBlockers={[]}
|
||||
generationAvailable
|
||||
onQuoteOffered={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
return actions;
|
||||
}
|
||||
|
||||
function referenceWorkspace({ offered = false } = {}) {
|
||||
const values = structuredClone(designValuesFixture);
|
||||
values.content.concept = '参考 @图片1 的小狗,让它在草地上奔跑';
|
||||
values.references = [{
|
||||
id: 'reference-1',
|
||||
asset_id: 'asset-reference',
|
||||
asset_revision: null,
|
||||
role: 'subject_identity',
|
||||
preserve: ['圆脸'],
|
||||
adapt: ['背景颜色'],
|
||||
do_not_copy: ['原图文字'],
|
||||
reviewed_observations: ['白色背景'],
|
||||
}];
|
||||
return designWorkspaceFixture({
|
||||
form: designFormFixture({
|
||||
specification: { schema_version: 1, values, field_decisions: {} },
|
||||
activeQuotes: offered ? [designQuoteFixture()] : [],
|
||||
}),
|
||||
assets: [{
|
||||
assetId: 'asset-reference',
|
||||
workspaceId: 'workspace-1',
|
||||
role: 'uploaded',
|
||||
mediaType: 'image',
|
||||
mimeType: 'image/png',
|
||||
width: 600,
|
||||
height: 600,
|
||||
durationMilliseconds: null,
|
||||
generationTaskId: null,
|
||||
createdAt: '2026-09-02T09:00:00.000Z',
|
||||
contentPath: '/api/design/assets/asset-reference/content',
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
describe('YouthCreationCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
});
|
||||
|
||||
it('shows a plain creation summary and reveals the offered quote after requesting it', async () => {
|
||||
const { onQuoteOffered, requestQuote } = renderCard();
|
||||
it('puts the editable creator prompt and compact visible parameters in one plan card', () => {
|
||||
renderCard();
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'AI 听懂的想法' })).toBeInTheDocument();
|
||||
expect(screen.getByText('会跟着聊天自动更新,不需要逐项填写。')).toBeInTheDocument();
|
||||
expect(screen.getByText('把便携咖啡机呈现为城市通勤中的精密工具')).toBeInTheDocument();
|
||||
expect(screen.getByText('图片')).toBeInTheDocument();
|
||||
expect(screen.getByText('竖版海报')).toBeInTheDocument();
|
||||
expect(screen.getByText('3:4')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('youth-creation-readiness')).getByText('想法已经清楚,可以检查制作方案')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Specification|revision|compiler|provider|immutable/i)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '看看制作方案' }));
|
||||
|
||||
expect(requestQuote).toHaveBeenCalledOnce();
|
||||
await waitFor(() => expect(onQuoteOffered).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it('does not surface legacy decision prompts as a form', () => {
|
||||
const workspace = designWorkspaceFixture({
|
||||
form: designFormFixture({
|
||||
decisionPrompts: [{
|
||||
id: 'choice-1',
|
||||
kind: 'question',
|
||||
basedOnSpecificationRevisionId: 'specification-revision-3',
|
||||
targetPaths: ['visual.art_direction'],
|
||||
title: '画面想要什么感觉?',
|
||||
body: '选一个你喜欢的方向。',
|
||||
options: [
|
||||
{ id: 'cartoon', label: '有趣的卡通', description: '颜色明快,轻松有趣' },
|
||||
{ id: 'cinema', label: '像电影一样', description: null },
|
||||
{ id: 'delegate', label: '你帮我选', description: null },
|
||||
],
|
||||
}, {
|
||||
id: 'choice-2',
|
||||
kind: 'question',
|
||||
basedOnSpecificationRevisionId: 'specification-revision-3',
|
||||
targetPaths: ['visual.palette'],
|
||||
title: '这个问题不该显示',
|
||||
body: '只显示一个选择就好。',
|
||||
options: [{ id: 'blue', label: '蓝色', description: null }],
|
||||
}],
|
||||
}),
|
||||
});
|
||||
const { resolveDecisionPrompt } = renderCard(workspace);
|
||||
|
||||
expect(screen.queryByTestId('youth-creation-choice')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('画面想要什么感觉?')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /有趣的卡通/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /你帮我选/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('这个问题不该显示')).not.toBeInTheDocument();
|
||||
expect(resolveDecisionPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows known must-have, must-avoid, reference, video, and plain blocker details only', () => {
|
||||
const values = structuredClone(designValuesFixture);
|
||||
values.intent.media = 'video';
|
||||
values.video.total_duration_seconds = 10;
|
||||
values.video.pacing = '很有活力';
|
||||
values.video.end_state = '机器人端出一盘星星饼干';
|
||||
values.video.shots = [{
|
||||
id: 'shot-1',
|
||||
start_milliseconds: 0,
|
||||
end_milliseconds: 10_000,
|
||||
story_beat: '机器人在厨房里做饭',
|
||||
framing: 'close-up',
|
||||
camera: 'dolly-in',
|
||||
subject_motion: '快速搅拌',
|
||||
environment_motion: null,
|
||||
focus_behavior: null,
|
||||
transition: null,
|
||||
}];
|
||||
values.constraints.must_include = ['远处的地球'];
|
||||
values.constraints.must_preserve = ['红围巾'];
|
||||
values.constraints.must_avoid = ['真实人物'];
|
||||
values.references = [{
|
||||
id: 'reference-1',
|
||||
asset_id: 'asset-reference',
|
||||
asset_revision: null,
|
||||
role: 'subject_identity',
|
||||
preserve: ['圆脸'],
|
||||
adapt: ['背景颜色'],
|
||||
do_not_copy: ['原图文字'],
|
||||
reviewed_observations: ['白色背景'],
|
||||
}];
|
||||
const workspace = designWorkspaceFixture({
|
||||
form: designFormFixture({
|
||||
specification: { schema_version: 1, values, field_decisions: {} },
|
||||
}),
|
||||
assets: [{
|
||||
assetId: 'asset-reference',
|
||||
workspaceId: 'workspace-1',
|
||||
role: 'uploaded',
|
||||
mediaType: 'image',
|
||||
mimeType: 'image/png',
|
||||
width: 600,
|
||||
height: 600,
|
||||
durationMilliseconds: null,
|
||||
generationTaskId: null,
|
||||
createdAt: '2026-09-02T09:00:00.000Z',
|
||||
contentPath: '/api/design/assets/asset-reference/content',
|
||||
}],
|
||||
});
|
||||
const blocker: DesignCompilationIssue = {
|
||||
code: 'aspect_ratio_required',
|
||||
message: 'Choose an aspect ratio supported by the frozen route.',
|
||||
severity: 'blocker',
|
||||
path: 'output.aspect_ratio',
|
||||
};
|
||||
|
||||
renderCard(workspace, { quoteBlockers: [blocker] });
|
||||
|
||||
fireEvent.click(screen.getByText('查看画面细节'));
|
||||
expect(screen.getByText('一定要有')).toBeInTheDocument();
|
||||
expect(screen.getByText('远处的地球')).toBeInTheDocument();
|
||||
expect(screen.getByText('想保留')).toBeInTheDocument();
|
||||
expect(screen.getByText('红围巾')).toBeInTheDocument();
|
||||
expect(screen.getByText('不要出现')).toBeInTheDocument();
|
||||
expect(screen.getByText('真实人物')).toBeInTheDocument();
|
||||
expect(screen.getByText('参考图片')).toBeInTheDocument();
|
||||
expect(screen.getByText('600 × 600')).toBeInTheDocument();
|
||||
expect(screen.getByText('圆脸')).toBeInTheDocument();
|
||||
expect(screen.getByText('背景颜色')).toBeInTheDocument();
|
||||
expect(screen.getByText('原图文字')).toBeInTheDocument();
|
||||
expect(screen.getByText('视频安排')).toBeInTheDocument();
|
||||
expect(screen.getByText('10 秒')).toBeInTheDocument();
|
||||
expect(screen.getByText('很有活力')).toBeInTheDocument();
|
||||
expect(screen.getByText('机器人端出一盘星星饼干')).toBeInTheDocument();
|
||||
expect(screen.getByText(/机器人在厨房里做饭/)).toBeInTheDocument();
|
||||
expect(screen.getByText('先选择作品形状。')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/frozen route/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('dolly-in')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('milliseconds')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Specification|revision|compiler|provider|immutable/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses a gentle empty-state CTA and sends focus back to the composer', () => {
|
||||
const values = structuredClone(designValuesFixture);
|
||||
values.intent.media = null;
|
||||
values.intent.creation_intent = null;
|
||||
values.intent.purpose = null;
|
||||
values.intent.channel = null;
|
||||
values.intent.audience = null;
|
||||
values.content.concept = null;
|
||||
values.output.aspect_ratio = null;
|
||||
values.output.orientation = null;
|
||||
const onFocusComposer = vi.fn();
|
||||
const { onOpenFineTune } = renderCard(
|
||||
designWorkspaceFixture({
|
||||
form: designFormFixture({
|
||||
specification: { schema_version: 1, values, field_decisions: {} },
|
||||
}),
|
||||
turns: [],
|
||||
}),
|
||||
{ onFocusComposer },
|
||||
expect(screen.getByRole('heading', { name: '制作方案' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: '创作提示词' })).toHaveValue(
|
||||
'把便携咖啡机呈现为城市通勤中的精密工具',
|
||||
);
|
||||
|
||||
expect(within(screen.getByTestId('youth-creation-readiness')).getByText('等你的第一个想法')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始描述' }));
|
||||
expect(onFocusComposer).toHaveBeenCalledOnce();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '手动调整(可选)' }));
|
||||
expect(onOpenFineTune).toHaveBeenCalledOnce();
|
||||
expect(screen.getByRole('combobox', { name: '类型' })).toHaveValue('image');
|
||||
expect(screen.getByRole('combobox', { name: '画幅' })).toHaveValue('3:4');
|
||||
expect(screen.getByRole('button', { name: '增加数量' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '准备制作方案' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('查看画面细节')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('手动调整')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not request a plan when generation is unavailable', () => {
|
||||
const { requestQuote, onFocusComposer } = renderCard(undefined, {
|
||||
generationAvailable: false,
|
||||
it('saves a final-prompt edit through the public Specification field', async () => {
|
||||
const actions = renderCard();
|
||||
const prompt = screen.getByRole('textbox', { name: '创作提示词' });
|
||||
|
||||
fireEvent.change(prompt, { target: { value: '更聚焦咖啡机,背景使用清晨地铁站' } });
|
||||
fireEvent.blur(prompt);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(actions.applyFieldOperations).toHaveBeenCalledWith([
|
||||
{ kind: 'set', path: 'content.concept', value: '更聚焦咖啡机,背景使用清晨地铁站' },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: '继续完善想法' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续完善想法' }));
|
||||
expect(requestQuote).not.toHaveBeenCalled();
|
||||
expect(onFocusComposer).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps confirming and generating states clear, then offers another version after a result', () => {
|
||||
const confirming = designWorkspaceFixture({
|
||||
it('recompiles and requotes after editing an offered plan', async () => {
|
||||
const workspace = designWorkspaceFixture({
|
||||
form: designFormFixture({ activeQuotes: [designQuoteFixture()] }),
|
||||
});
|
||||
const { onOpenFineTune: onConfirmingFineTune } = renderCard(confirming);
|
||||
expect(screen.getByText('方案准备好了,确认后就开始制作。')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续调整' }));
|
||||
expect(onConfirmingFineTune).toHaveBeenCalledOnce();
|
||||
const actions = renderCard(workspace);
|
||||
const prompt = screen.getByRole('textbox', { name: '创作提示词' });
|
||||
|
||||
const resultAsset = {
|
||||
assetId: 'asset-result',
|
||||
workspaceId: 'workspace-1',
|
||||
role: 'generated' as const,
|
||||
mediaType: 'image' as const,
|
||||
mimeType: 'image/png',
|
||||
width: 1024,
|
||||
height: 1365,
|
||||
durationMilliseconds: null,
|
||||
generationTaskId: 'task-1',
|
||||
createdAt: '2026-09-02T09:05:00.000Z',
|
||||
contentPath: '/api/design/assets/asset-result/content',
|
||||
};
|
||||
const generating = designWorkspaceFixture({ tasks: [taskFixture()] });
|
||||
const result = designWorkspaceFixture({
|
||||
tasks: [taskFixture({
|
||||
status: 'succeeded',
|
||||
progress: { stage: 'completed', completedRequiredSteps: 1, totalRequiredSteps: 1 },
|
||||
resultAssetIds: ['asset-result'],
|
||||
nextActions: ['view_result', 'revise_design', 'generate_again'],
|
||||
})],
|
||||
assets: [resultAsset],
|
||||
fireEvent.change(prompt, { target: { value: '改成夜晚城市街景' } });
|
||||
fireEvent.blur(prompt);
|
||||
|
||||
await waitFor(() => expect(actions.applyFieldOperations).toHaveBeenCalledOnce());
|
||||
await waitFor(() => expect(actions.requestQuote).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it('keeps reference rows binding-only while the prompt carries image usage', () => {
|
||||
renderCard(referenceWorkspace());
|
||||
|
||||
expect(screen.getByRole('textbox', { name: '创作提示词' })).toHaveValue(
|
||||
'参考 @图片1 的小狗,让它在草地上奔跑',
|
||||
);
|
||||
expect(screen.getByText('@图片1')).toBeInTheDocument();
|
||||
expect(screen.getByText('已引用')).toBeInTheDocument();
|
||||
expect(screen.getByText('参考图 1 · 600×600')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '替换参考图 1' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '删除参考图 1' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('圆脸')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('背景颜色')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('原图文字')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/主体参考|最想保留|可以改变|不要照着画/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers existing reference tokens when the user types @', () => {
|
||||
renderCard(referenceWorkspace());
|
||||
const prompt = screen.getByRole('textbox', { name: '创作提示词' });
|
||||
const nextValue = '让 ';
|
||||
|
||||
fireEvent.change(prompt, {
|
||||
target: { value: `${nextValue}@`, selectionStart: nextValue.length + 1 },
|
||||
});
|
||||
|
||||
// A card is intentionally remounted between resource states so each assertion
|
||||
// observes the state a user sees at that moment.
|
||||
cleanup();
|
||||
renderCard(generating);
|
||||
expect(within(screen.getByTestId('youth-creation-readiness')).getByText('正在制作,请稍等一下')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '正在制作' })).toBeDisabled();
|
||||
expect(screen.getByRole('listbox', { name: '可引用的图片' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: '@图片1' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: '上传新的参考图' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
cleanup();
|
||||
const { requestQuote, onOpenFineTune } = renderCard(result);
|
||||
expect(within(screen.getByTestId('youth-creation-readiness')).getByText('完成了,看看你的作品吧')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续调整' }));
|
||||
expect(onOpenFineTune).toHaveBeenCalledOnce();
|
||||
fireEvent.click(screen.getByRole('button', { name: '做一个新版本' }));
|
||||
expect(requestQuote).toHaveBeenCalledOnce();
|
||||
it('does not duplicate a reference token that is already present', () => {
|
||||
const actions = renderCard(referenceWorkspace());
|
||||
|
||||
fireEvent.click(screen.getByTitle('在提示词中插入 @图片1'));
|
||||
|
||||
expect(screen.getByRole('textbox', { name: '创作提示词' })).toHaveValue(
|
||||
'参考 @图片1 的小狗,让它在草地上奔跑',
|
||||
);
|
||||
expect(actions.applyFieldOperations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks confirmation while a prompt alias has no bound image', () => {
|
||||
const workspace = referenceWorkspace({ offered: true });
|
||||
workspace.form.specification.values.content.concept = '参考 @图片1 和 @图片2 制作双人合照';
|
||||
renderCard(workspace);
|
||||
|
||||
expect(screen.getByText('@图片2 还没有绑定图片')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '上传 @图片2' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '确认并开始制作' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('binds an already typed next alias without appending it again', async () => {
|
||||
const workspace = referenceWorkspace();
|
||||
workspace.form.specification.values.content.concept = '参考 @图片1 和 @图片2 制作双人合照';
|
||||
uploadMock.mockResolvedValueOnce({
|
||||
assetId: 'asset-reference-2',
|
||||
workspaceId: 'workspace-1',
|
||||
role: 'uploaded',
|
||||
mediaType: 'image',
|
||||
mimeType: 'image/png',
|
||||
width: 800,
|
||||
height: 800,
|
||||
durationMilliseconds: null,
|
||||
generationTaskId: null,
|
||||
createdAt: '2026-09-02T10:00:00.000Z',
|
||||
contentPath: '/api/design/assets/asset-reference-2/content',
|
||||
});
|
||||
const actions = renderCard(workspace);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('选择参考图片'), {
|
||||
target: { files: [new File(['image'], 'second.png', { type: 'image/png' })] },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(actions.applyFieldOperations).toHaveBeenCalledOnce());
|
||||
const operations = actions.applyFieldOperations.mock.calls[0]?.[0];
|
||||
expect(operations).toHaveLength(1);
|
||||
expect(operations?.[0]).toMatchObject({
|
||||
kind: 'set',
|
||||
path: 'references',
|
||||
value: [
|
||||
expect.objectContaining({ asset_id: 'asset-reference' }),
|
||||
expect.objectContaining({ asset_id: 'asset-reference-2' }),
|
||||
],
|
||||
});
|
||||
expect(screen.getByRole('textbox', { name: '创作提示词' })).toHaveValue(
|
||||
'参考 @图片1 和 @图片2 制作双人合照',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a binding and its prompt token atomically', async () => {
|
||||
const actions = renderCard(referenceWorkspace());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除参考图 1' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(actions.applyFieldOperations).toHaveBeenCalledWith([
|
||||
{ kind: 'clear', path: 'references' },
|
||||
{ kind: 'set', path: 'content.concept', value: '参考 的小狗,让它在草地上奔跑' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('submits compact parameter edits and confirms only the offered Quote identity', async () => {
|
||||
const workspace = referenceWorkspace({ offered: true });
|
||||
const actions = renderCard(workspace);
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '画幅' }), { target: { value: '16:9' } });
|
||||
await waitFor(() => {
|
||||
expect(actions.applyFieldOperations).toHaveBeenCalledWith([
|
||||
{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' },
|
||||
{ kind: 'set', path: 'output.orientation', value: 'landscape' },
|
||||
]);
|
||||
});
|
||||
await waitFor(() => expect(actions.requestQuote).toHaveBeenCalledOnce());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认并开始制作' }));
|
||||
expect(actions.confirmGeneration).toHaveBeenCalledWith('quote-1');
|
||||
});
|
||||
|
||||
it('collapses a confirmed plan into the expandable production history', () => {
|
||||
const workspace = designWorkspaceFixture({ tasks: [taskFixture()] });
|
||||
useImageWorkspaceStore.setState({ pendingOperations: {} });
|
||||
const { container } = render(
|
||||
<>
|
||||
<DesignPlanHistory workspace={workspace} />
|
||||
<YouthCreationCard workspace={workspace} quoteBlockers={[]} generationAvailable />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('design-plan-history-task-1')).toBeInTheDocument();
|
||||
expect(screen.getByText('制作中')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-testid="youth-creation-card"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows safe Chinese task guidance instead of provider messages', () => {
|
||||
render(<DesignTaskPanel tasks={[taskFixture({
|
||||
status: 'failed',
|
||||
progress: { stage: 'failed', completedRequiredSteps: 0, totalRequiredSteps: 1 },
|
||||
issues: [{
|
||||
code: 'asset_rejected',
|
||||
message: 'Provider policy rejected the generated asset.',
|
||||
recoveryOwner: 'platform',
|
||||
}],
|
||||
nextActions: ['revise_design'],
|
||||
})]} />);
|
||||
const workspace = designWorkspaceFixture({
|
||||
tasks: [taskFixture({
|
||||
status: 'failed',
|
||||
progress: { stage: 'failed', completedRequiredSteps: 0, totalRequiredSteps: 1 },
|
||||
issues: [{
|
||||
code: 'asset_rejected',
|
||||
message: 'Provider policy rejected the generated asset.',
|
||||
recoveryOwner: 'platform',
|
||||
}],
|
||||
nextActions: ['revise_design'],
|
||||
})],
|
||||
});
|
||||
render(<DesignPlanHistory workspace={workspace} />);
|
||||
fireEvent.click(screen.getByText('制作记录'));
|
||||
|
||||
expect(screen.getByText('作品没有通过内容检查,可以换一种安全的说法再试。')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Provider policy/i)).not.toBeInTheDocument();
|
||||
|
||||
Reference in New Issue
Block a user