Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/features/quick-start-agent/boundaries.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFileSync, readdirSync } from 'node:fs'
import { extname, join, resolve } from 'node:path'
import { basename, extname, join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'

const featureDirectory = resolve(import.meta.dirname)
Expand All @@ -22,7 +22,7 @@ describe('quick-start-agent architecture boundary', () => {
it('keeps business imports out of the Agent core', () => {
const forbidden = ['@/pages', '@/entities', '@/shared/api', '@/features']
const featureDependencies = productionFiles(featureDirectory)
.filter((file) => !file.endsWith('/production.ts'))
.filter((file) => basename(file) !== 'production.ts')
.flatMap((file) => moduleSpecifiers(readFileSync(file, 'utf8')))

expect(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest'

import type { Character, CharacterApis, WorkflowRunApis } from '@/entities'
import { createExistingCharacterActionRun } from './existing-character-action'

const character: Character = {
id: '51',
projectId: '42',
workflowRunId: 'original-run',
name: '轻装信使',
description: '戴兜帽的像素信使',
referenceImageUrl: '/reference.png',
dataVersion: 1,
status: 1,
templates: [
{
direction: 'east',
sourceDirection: null,
mirrorX: false,
imageUrl: '/master.png',
},
{
direction: 'west',
sourceDirection: 'east',
mirrorX: true,
imageUrl: null,
},
{
direction: 'north',
sourceDirection: null,
mirrorX: false,
imageUrl: '/master-north.png',
},
],
outfits: [
{
id: 'outfit-default',
characterId: '51',
name: '常态造型',
description: null,
previewUrl: '/master.png',
model3dUrl: null,
actions: [],
},
],
}

describe('createExistingCharacterActionRun', () => {
it('creates a new run with the existing character and outfit template already completed', async () => {
const create = vi.fn(async (input) => ({
id: 'new-run',
projectId: input.projectId,
version: 1,
storageStatus: 'active' as const,
nodes: input.nodes,
}))

const result = await createExistingCharacterActionRun(
{ characterId: '51', outfitId: 'outfit-default' },
{
characterApis: { get: vi.fn(async () => character) } as Pick<CharacterApis, 'get'>,
workflowRunApis: { create } as Pick<WorkflowRunApis, 'create'>,
},
)

expect(result.run.id).toBe('new-run')
expect(create).toHaveBeenCalledWith({
projectId: '42',
nodes: [
expect.objectContaining({
type: 'character-setup',
status: 'passed',
input: expect.objectContaining({
characterId: '51',
referenceMedia: ['/master.png', '/master-north.png'],
}),
}),
expect.objectContaining({
type: 'character-template',
status: 'passed',
selectedImageUrl: '/master.png',
selectedImages: { east: '/master.png', north: '/master-north.png' },
}),
],
})
})

it('refuses to guess another outfit or template', async () => {
const dependencies = {
characterApis: { get: vi.fn(async () => character) } as Pick<CharacterApis, 'get'>,
workflowRunApis: { create: vi.fn() } as Pick<WorkflowRunApis, 'create'>,
}

await expect(
createExistingCharacterActionRun({ characterId: '51', outfitId: 'missing' }, dependencies),
).rejects.toThrow('当前造型还没有可用的角色母版,请先完成定妆再生成动作')
expect(dependencies.workflowRunApis.create).not.toHaveBeenCalled()
})

it.each([
[{ ...character, description: null }, '轻装信使'],
[{ ...character, name: null, description: null }, '现有角色'],
])('keeps a usable identity prompt when description is absent', async (candidate, prompt) => {
const create = vi.fn(async (input) => ({
id: 'new-run',
projectId: input.projectId,
version: 1,
storageStatus: 'active' as const,
nodes: input.nodes,
}))

await createExistingCharacterActionRun(
{ characterId: candidate.id, outfitId: 'outfit-default' },
{
characterApis: { get: vi.fn(async () => candidate) } as Pick<CharacterApis, 'get'>,
workflowRunApis: { create } as Pick<WorkflowRunApis, 'create'>,
},
)

expect(create.mock.calls[0]?.[0].nodes[0]).toMatchObject({ input: { prompt } })
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type {
Character,
CharacterApis,
MediaReference,
Outfit,
WorkflowNode,
WorkflowRun,
WorkflowRunApis,
} from '@/entities'
import { characterTemplateImages } from '@/entities'

const MISSING_TEMPLATE_MESSAGE = '当前造型还没有可用的角色母版,请先完成定妆再生成动作'

export interface ExistingCharacterActionTarget {
readonly characterId: Character['id']
readonly outfitId: Outfit['id']
}

interface ExistingCharacterActionDependencies {
readonly characterApis: Pick<CharacterApis, 'get'>
readonly workflowRunApis: Pick<WorkflowRunApis, 'create'>
}

export interface ExistingCharacterActionRun {
readonly run: WorkflowRun
readonly character: Character
readonly outfit: Outfit
}

function existingCharacterNodes(character: Character, outfit: Outfit): WorkflowNode[] {
const selectedImages = characterTemplateImages(character.templates)
const templateUrl = selectedImages.east ?? outfit.previewUrl ?? character.referenceImageUrl
if (!templateUrl) throw new Error(MISSING_TEMPLATE_MESSAGE)
if (!selectedImages.east) selectedImages.east = templateUrl
const prompt = character.description?.trim() || character.name?.trim() || '现有角色'
const referenceMedia = [...new Set(Object.values(selectedImages))] as MediaReference[]

return [
{
id: 'character-setup',
type: 'character-setup',
status: 'passed',
phase: 'completed',
dependsOnNodeIds: [],
generations: [],
error: null,
input: {
characterId: character.id,
prompt,
referenceMedia,
},
},
{
id: 'character-template',
type: 'character-template',
status: 'passed',
phase: 'completed',
dependsOnNodeIds: ['character-setup'],
generations: [],
error: null,
selectedImageUrl: templateUrl,
selectedImages,
},
]
}

/** 为现有角色的新动作创建独立 Run;原角色创建 Run 保持只读。 */
export async function createExistingCharacterActionRun(
target: ExistingCharacterActionTarget,
dependencies: ExistingCharacterActionDependencies,
): Promise<ExistingCharacterActionRun> {
const character = await dependencies.characterApis.get(target.characterId)
const outfit = character.outfits.find((candidate) => candidate.id === target.outfitId)
if (!outfit) throw new Error(MISSING_TEMPLATE_MESSAGE)

const run = await dependencies.workflowRunApis.create({
projectId: character.projectId,
nodes: existingCharacterNodes(character, outfit),
})
return { run, character, outfit }
}
5 changes: 5 additions & 0 deletions frontend/src/features/workflow-controller/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { createAutoPrepareProject, createWorkflowController } from './controller'
export { createExistingCharacterActionRun } from './existing-character-action'
export type {
AddActionInput,
ApplyGenerationResultInput,
Expand All @@ -10,3 +11,7 @@ export type {
StartCharacterGenerationResult,
WorkflowController,
} from './controller'
export type {
ExistingCharacterActionRun,
ExistingCharacterActionTarget,
} from './existing-character-action'
114 changes: 112 additions & 2 deletions frontend/src/pages/character-detail/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MemoryRouter } from 'react-router'

import { AppRoutes } from '@/app'
import { workflowRunApis } from '@/entities'
import { AuthenticatedAuthSession } from '@/test/auth-session'
import { createProjectAssetsBackend } from '@/test/project-assets-backend'

afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})
Expand Down Expand Up @@ -51,7 +53,7 @@ describe('CharacterDetailPage', () => {
expect(preview.getAttribute('decoding')).toBe('async')
}
expect(screen.queryByText('GIF')).toBeNull()
expect(screen.queryByRole('button', { name: '增加动作' })).toBeNull()
expect(screen.getByRole('button', { name: '增加动作' }).hasAttribute('disabled')).toBe(false)
const exportEntry = screen.getByRole('button', { name: '导出资产包' })
expect(exportEntry.className).toContain('rounded-full')
expect(screen.queryByText('当前阶段')).toBeNull()
Expand All @@ -62,6 +64,74 @@ describe('CharacterDetailPage', () => {
expect(playtestEntry.parentElement?.className).toContain('items-start')
})

it('lets the user choose Quick Start or Workflow Editor for the selected outfit', async () => {
renderCharacter('51')
await screen.findByRole('heading', { name: '轻装信使' })

fireEvent.click(screen.getByRole('button', { name: '增加动作' }))

expect(screen.getByRole('dialog', { name: '选择动作创建方式' })).toBeTruthy()
expect(screen.getByRole('link', { name: '使用 Quick Start' }).getAttribute('href')).toBe(
'/quick-start?characterId=51&outfitId=outfit-default',
)
expect(screen.getByRole('button', { name: '使用 Workflow Editor' })).toBeTruthy()

fireEvent.click(screen.getByRole('button', { name: '关闭动作创建方式' }))
expect(screen.queryByRole('dialog', { name: '选择动作创建方式' })).toBeNull()

fireEvent.click(screen.getByRole('button', { name: '增加动作' }))
const dialog = screen.getByRole('dialog', { name: '选择动作创建方式' })
fireEvent.mouseDown(dialog.parentElement!)
expect(screen.queryByRole('dialog', { name: '选择动作创建方式' })).toBeNull()
})

it('keeps the chooser open and reports Workflow Editor creation failures', async () => {
vi.spyOn(workflowRunApis, 'create').mockRejectedValue(new Error('工作流暂时不可用'))
renderCharacter('51')
await screen.findByRole('heading', { name: '轻装信使' })

fireEvent.click(screen.getByRole('button', { name: '增加动作' }))
fireEvent.click(screen.getByRole('button', { name: '使用 Workflow Editor' }))

expect(await screen.findByRole('alert')).toHaveProperty('textContent', '工作流暂时不可用')
expect(screen.getByRole('dialog', { name: '选择动作创建方式' })).toBeTruthy()
expect(
screen.getByRole('button', { name: '使用 Workflow Editor' }).hasAttribute('disabled'),
).toBe(false)
})

it('creates an independent template-bound run before opening Workflow Editor', async () => {
const create = vi.spyOn(workflowRunApis, 'create').mockImplementation(async (input) => ({
id: 'new-action-run',
projectId: input.projectId,
version: 1,
storageStatus: 'active',
nodes: input.nodes,
}))
renderCharacter('51')
await screen.findByRole('heading', { name: '轻装信使' })

fireEvent.click(screen.getByRole('button', { name: '增加动作' }))
fireEvent.click(screen.getByRole('button', { name: '使用 Workflow Editor' }))

await waitFor(() => expect(create).toHaveBeenCalledOnce())
expect(create).toHaveBeenCalledWith({
projectId: '42',
nodes: expect.arrayContaining([
expect.objectContaining({
type: 'character-setup',
status: 'passed',
input: expect.objectContaining({ characterId: '51' }),
}),
expect.objectContaining({
type: 'character-template',
status: 'passed',
selectedImageUrl: 'https://cdn.windup.test/messenger-outfit.png',
}),
]),
})
})

it('expands an Action into backend Frames sorted by index', async () => {
renderCharacter('51')

Expand All @@ -82,13 +152,53 @@ describe('CharacterDetailPage', () => {
expect(scroller?.querySelector('ol')?.className).toContain('min-w-max')
})

it('allows adding an action when the character has directional templates without an outfit preview', async () => {
const backend = createProjectAssetsBackend()
vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test')
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const response = await backend.fetch(input, init)
if (!request.url.endsWith('/characters/52')) return response

const payload = (await response.json()) as {
data: { character_data: { templates?: unknown[] } }
[key: string]: unknown
}
payload.data.character_data.templates = [
{
direction: 'east',
source_direction: null,
mirror_x: false,
image_url: 'https://cdn.windup.test/draft-east.png',
},
]
return new Response(JSON.stringify(payload), {
headers: { 'content-type': 'application/json' },
})
})

render(
<AuthenticatedAuthSession>
<MemoryRouter initialEntries={['/projects/42/assets/52']}>
<AppRoutes />
</MemoryRouter>
</AuthenticatedAuthSession>,
)

expect(await screen.findByRole('heading', { name: '待定角色' })).toBeTruthy()
expect(screen.getByRole('button', { name: '增加动作' }).hasAttribute('disabled')).toBe(false)
})

it('preserves the Outfit level when no Action exists', async () => {
renderCharacter('52')

expect(await screen.findByRole('heading', { name: '待定角色' })).toBeTruthy()
expect(screen.queryByRole('combobox', { name: '选择造型' })).toBeNull()
expect(screen.getByText('这个造型还没有动作')).toBeTruthy()
expect(screen.queryByRole('link', { name: '在预览台打开当前造型' })).toBeNull()
const addAction = screen.getByRole('button', { name: '增加动作' })
expect(addAction.hasAttribute('disabled')).toBe(true)
expect(addAction.getAttribute('title')).toBe('当前造型缺少角色母版')
})

it('renders a real empty state when the Character has no Outfit', async () => {
Expand Down
Loading
Loading