import * as React from 'react' import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { NextIntlClientProvider } from 'next-intl' import type { AbstractIntlMessages } from 'next-intl' const idleMutation = { isPending: false, mutate: vi.fn(), } vi.mock('@/lib/query/mutations', () => ({ useGenerateCharacterImage: () => idleMutation, useSelectCharacterImage: () => idleMutation, useUndoCharacterImage: () => idleMutation, useUploadCharacterImage: () => idleMutation, useDeleteCharacter: () => idleMutation, useDeleteCharacterAppearance: () => idleMutation, useUploadCharacterVoice: () => idleMutation, useGenerateLocationImage: () => idleMutation, useSelectLocationImage: () => idleMutation, useUndoLocationImage: () => idleMutation, useUploadLocationImage: () => idleMutation, useDeleteLocation: () => idleMutation, })) vi.mock('@/components/ui/icons', () => ({ AppIcon: (props: { className?: string; name?: string }) => createElement('span', { className: props.className, 'data-icon': props.name }), })) vi.mock('@/components/task/TaskStatusOverlay', () => ({ default: () => createElement('div', null, 'overlay'), })) vi.mock('@/components/task/TaskStatusInline', () => ({ default: () => createElement('span', null, 'inline'), })) vi.mock('@/components/media/MediaImageWithLoading', () => ({ MediaImageWithLoading: (props: { containerClassName?: string; className?: string }) => createElement('div', { className: [props.containerClassName, props.className].filter(Boolean).join(' '), }), })) vi.mock('@/components/image-generation/ImageGenerationInlineCountButton', () => ({ default: () => createElement('button', null, 'count'), })) vi.mock('@/lib/task/presentation', () => ({ resolveTaskPresentationState: () => null, })) vi.mock('@/lib/image-generation/use-image-generation-count', () => ({ useImageGenerationCount: () => ({ count: 1, setCount: vi.fn(), }), })) vi.mock('@/lib/image-generation/count', () => ({ getImageGenerationCountOptions: () => [{ value: 1, label: '1' }], })) vi.mock('@/app/[locale]/workspace/asset-hub/components/VoiceSettings', () => ({ default: () => createElement('div', null, 'voice-settings'), })) const messages = { assetHub: { generateFailed: '生成失败', selectFailed: '选择失败', uploadFailed: '上传失败', confirmDeleteLocation: '确认删除场景', confirmDeleteProp: '确认删除道具', confirmDeleteCharacter: '确认删除角色', cancel: '取消', delete: '删除', propLabel: '道具', locationLabel: '场景', }, assets: { image: { generateCountPrefix: '生成', generateCountSuffix: '张', generating: '生成中', generatingPlaceholder: '正在生成', regenerateStuck: '重新生成', regenCountPrefix: '重生成', undo: '撤回', upload: '上传', uploadReplace: '替换', edit: '编辑', selectCount: '选择数量', confirmOption: '确认选择', optionNumber: '方案 {number}', }, common: { generateFailed: '生成失败', }, location: { regenerateImage: '重生成场景', edit: '编辑场景', delete: '删除场景', }, prop: { regenerateImage: '重生成道具', edit: '编辑道具', delete: '删除道具', }, character: { deleteWhole: '删除整个角色', primary: '主形象', secondary: '子形象', delete: '删除角色', deleteOptions: '删除选项', }, video: { panelCard: { editPrompt: '编辑', }, }, }, } as const const TestIntlProvider = NextIntlClientProvider as React.ComponentType<{ locale: string messages: AbstractIntlMessages timeZone: string children?: React.ReactNode }> function renderWithIntl(node: React.ReactElement) { return renderToStaticMarkup( createElement( TestIntlProvider, { locale: 'zh', messages: messages as unknown as AbstractIntlMessages, timeZone: 'Asia/Shanghai', }, node, ), ) } describe('asset hub card aspect ratio', () => { it('keeps prop cards at the same 3:2 ratio as character assets while generation is running', async () => { Reflect.set(globalThis, 'React', React) const { default: LocationCard } = await import('@/app/[locale]/workspace/asset-hub/components/LocationCard') const html = renderWithIntl( createElement(LocationCard, { location: { id: 'prop-1', name: '鼠标', summary: '电脑鼠标', folderId: null, images: [ { id: 'prop-image-1', imageIndex: 0, description: null, imageUrl: null, previousImageUrl: null, isSelected: false, imageTaskRunning: true, }, ], }, assetType: 'prop', }), ) expect(html).toContain('aspect-[3/2]') expect(html).toContain('data-icon="image"') expect(html).not.toContain('min-h-[100px]') }) it('keeps location cards square while generation is running', async () => { Reflect.set(globalThis, 'React', React) const { default: LocationCard } = await import('@/app/[locale]/workspace/asset-hub/components/LocationCard') const html = renderWithIntl( createElement(LocationCard, { location: { id: 'location-1', name: '餐厅', summary: '极简餐厅', folderId: null, images: [ { id: 'location-image-1', imageIndex: 0, description: null, imageUrl: null, previousImageUrl: null, isSelected: false, imageTaskRunning: true, }, ], }, assetType: 'location', }), ) expect(html).toContain('aspect-square') expect(html).toContain('data-icon="image"') expect(html).not.toContain('min-h-[100px]') }) it('keeps character cards at the fixed 3:2 ratio while generation is running', async () => { Reflect.set(globalThis, 'React', React) const { CharacterCard } = await import('@/app/[locale]/workspace/asset-hub/components/CharacterCard') const html = renderWithIntl( createElement(CharacterCard, { character: { id: 'character-1', name: '沈烬', folderId: null, customVoiceUrl: null, appearances: [ { id: 'appearance-1', appearanceIndex: 0, changeReason: '默认形象', description: null, imageUrl: null, imageUrls: [], selectedIndex: null, previousImageUrl: null, previousImageUrls: [], imageTaskRunning: true, }, ], }, }), ) expect(html).toContain('aspect-[3/2]') expect(html).not.toContain('min-h-[100px]') }) })