Files
Genarrative/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx
T
k88936 676bd524ee
Project CI / Repository checks (push) Successful in 1m23s
Project CI / Frontend tests (push) Successful in 3m1s
Project CI / Backend tests (push) Successful in 3m51s
Project CI / Native shell tests (push) Successful in 13m32s
滑动条样式调整 (#149)
示例来自微信:
![shotmd-1786091231.jpg](/attachments/b59bae77-00ca-4669-8a54-a9786ae53c93)

before:
- firefox:
![shotmd-1786091298.jpg](/attachments/80bbd46e-551d-4b44-abe4-d3d0ecaa34dd)

- edge:
![shotmd-1786091062.jpg](/attachments/7918c523-1b5b-4439-926d-591e0029c5b9)

after:
- firefox:
![shotmd-1786110119.jpg](/attachments/31b22537-236b-4782-bc47-28ac5ecb0f9f)
![shotmd-1786110060.jpg](/attachments/b48d5343-fd07-451d-943a-55000600e248)

- edge:

![shotmd-1786110050.jpg](/attachments/cfd0d8e4-7d52-45f2-bab8-1b573b3a43b0)

- 使用 Lexical + OverlayScrollbars 实现统一可控的滑动条样式, 取代原来的textarea

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/149
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-08 15:10:16 +08:00

265 lines
8.3 KiB
TypeScript

/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { setPlainTextEditorValue } from '../common/AutoGrowTextArea.test-utils';
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
import type {
CanvasLayer,
CharacterAnimationPanelState,
} from './ImageCanvasEditorTypes';
function createSourceLayer(): CanvasLayer {
return {
id: 'layer-a',
resourceId: 'resource-a',
title: '角色源图',
src: 'data:image/png;base64,Y2hhcmFjdGVy',
x: 0,
y: 0,
width: 128,
height: 128,
originalWidth: 128,
originalHeight: 128,
zIndex: 1,
sourceType: 'uploaded',
};
}
function createPanel(
patch: Partial<CharacterAnimationPanelState> = {},
): CharacterAnimationPanelState {
return {
sourceLayerId: 'layer-a',
promptText: '待机动作',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
...patch,
};
}
function CharacterAnimationPanelHarness({
initialPanel,
onSubmit = vi.fn(),
onUpdateDuration,
}: {
initialPanel: CharacterAnimationPanelState;
onSubmit?: () => void;
onUpdateDuration?: (frameCountValue: string) => void;
}) {
const [panel, setPanel] = useState<CharacterAnimationPanelState | null>(
initialPanel,
);
const updateDuration =
onUpdateDuration ??
((frameCountValue: string) => {
const frameCount = Number(frameCountValue);
setPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
frameCount: frameCount === 48 ? 48 : frameCount === 40 ? 40 : 32,
durationSeconds:
frameCount === 48 ? 6 : frameCount === 40 ? 5 : 4,
status:
currentPanel.status === 'failed' ? 'idle' : currentPanel.status,
errorMessage:
currentPanel.status === 'failed'
? undefined
: currentPanel.errorMessage,
}
: currentPanel,
);
});
return panel ? (
<div>
<ImageCanvasCharacterAnimationPanelView
panel={panel}
sourceLayer={createSourceLayer()}
style={{ left: 12, top: 24 }}
price={18}
setCharacterAnimationPanel={setPanel}
onUpdateDuration={updateDuration}
onSubmit={onSubmit}
/>
<output aria-label="当前动画描述">{panel.promptText}</output>
<output aria-label="当前分辨率">{panel.resolution}</output>
<output aria-label="当前比例">{panel.ratio}</output>
<output aria-label="当前帧数">{panel.frameCount}</output>
<output aria-label="当前时长">{panel.durationSeconds}</output>
<output aria-label="当前状态">{panel.status}</output>
<output aria-label="当前错误">{panel.errorMessage ?? '-'}</output>
</div>
) : (
<output aria-label="面板状态">closed</output>
);
}
describe('ImageCanvasCharacterAnimationPanelView', () => {
it('keeps source reference first and updates prompt, resolution and ratio from a menu while clearing failed state', () => {
render(
<CharacterAnimationPanelHarness
initialPanel={createPanel({
status: 'failed',
errorMessage: '旧错误',
})}
/>,
);
const description = screen.getByLabelText('动画描述');
expect(description.closest('.auto-grow-text-area')?.className).toContain(
'auto-grow-text-area',
);
expect(description.className).not.toContain('platform-text-field');
setPlainTextEditorValue(description, 'a'.repeat(4001));
fireEvent.click(
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
);
const menu = screen.getByRole('menu', { name: '动画参数选项' });
fireEvent.click(screen.getByRole('button', { name: '清晰度 720p' }));
fireEvent.click(screen.getByRole('button', { name: '比例 16:9' }));
expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy();
expect(screen.getByLabelText('当前动画描述').textContent).toHaveLength(
4000,
);
expect(screen.getByLabelText('当前分辨率').textContent).toBe('720p');
expect(screen.getByLabelText('当前比例').textContent).toBe('16:9');
expect(menu).toBeTruthy();
expect(
menu
.closest('.image-canvas-editor__portal-theme')
?.classList.contains('platform-theme--light'),
).toBe(true);
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
});
it('clicks the parent animation panel to collapse the parameter menu', () => {
render(<CharacterAnimationPanelHarness initialPanel={createPanel()} />);
fireEvent.click(
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
);
expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy();
fireEvent.click(screen.getByRole('textbox', { name: '动画描述' }));
expect(screen.queryByRole('menu', { name: '动画参数选项' })).toBeNull();
});
it('applies preset prompt and duration updates', () => {
const updateDuration = vi.fn();
render(
<CharacterAnimationPanelHarness
initialPanel={createPanel({
status: 'failed',
errorMessage: '旧错误',
})}
onUpdateDuration={updateDuration}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '行走' }));
fireEvent.click(
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
);
fireEvent.click(screen.getByRole('button', { name: '时长 48帧·6秒' }));
expect(screen.getByLabelText('当前动画描述').textContent).toBe(
'循环行走动作,步伐稳定。',
);
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
expect(updateDuration).toHaveBeenCalledWith('48');
});
it('submits only when idle and closes through its interface', () => {
const submitCharacterAnimation = vi.fn();
render(
<CharacterAnimationPanelHarness
initialPanel={createPanel()}
onSubmit={submitCharacterAnimation}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '生成18泥点' }));
fireEvent.click(
screen.getByRole('button', { name: '关闭角色动画生成面板' }),
);
expect(submitCharacterAnimation).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('面板状态').textContent).toBe('closed');
});
it('keeps generating panels disabled and does not submit', () => {
const submitCharacterAnimation = vi.fn();
render(
<CharacterAnimationPanelHarness
initialPanel={createPanel({ status: 'generating' })}
onSubmit={submitCharacterAnimation}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '生成中' }));
const description = screen.getByLabelText('动画描述');
expect(description.getAttribute('aria-disabled')).toBe('true');
expect(
description
.closest('.auto-grow-text-area')
?.getAttribute('data-disabled'),
).toBe('true');
expect(
(screen.getByRole('button', { name: '待机' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(submitCharacterAnimation).not.toHaveBeenCalled();
});
it('renders completed frame count and failed error state', () => {
const result = {
taskId: 'task-a',
model: 'seedance2.0-fast' as const,
prompt: '行走动作',
previewVideoPath: '/preview.mp4',
frames: [],
frameCount: 40,
durationSeconds: 5,
fps: 8,
priceMudPoints: 18,
};
const { rerender } = render(
<CharacterAnimationPanelHarness
initialPanel={createPanel({
status: 'completed',
result,
})}
/>,
);
expect(screen.getByText('已生成 40 帧')).toBeTruthy();
rerender(
<CharacterAnimationPanelHarness
key="failed"
initialPanel={createPanel({
status: 'failed',
errorMessage: '生成失败',
})}
/>,
);
expect(screen.getByRole('alert').textContent).toContain('生成失败');
});
});