a03f13d0a5
原问题
```
历史角色动作是一个兼容例外:
后端允许它没有 editor_project_resource 资源行,只要布局自身保存了完整图片序列帧。
```
- 把生成动作的原视频asstetkind改为 video, 只把图片序列作为action
- db asset新增字段image_sequence_frames_json image_seq_duration_ms等, (原来动作的这些数据存在generation-input中,并不合适)
修改了externaljob,把这部分数据正确填写到数据库中。
- 为避免到处fallback,做了数据库迁移, 脚本: `scripts/spacetime-normalize-editor-character-actions.mjs` 手动进行过画布+素材库中动作序列帧+原始视频 迁移的测试
- 移除preview_video_path字段, reason:
>preview video
→ separate resource/asset with assetKind="video"
final transparent action
→ assetKind="character-animation"
→ source_resource_id points to the preview-video resource
→ image_sequence_frames_json contains the actual playable result
>
> So preview_video_path on the final action duplicates the source video resource’s image_src.
- 移除每个frame的index字段 原因: 这个只用于后端内部处理时有一个并发请求, 每个赋一个index方便收集, 后续没有再用到,且与数组本身重复
- 清除副产品preview video的generation_input_json, 因为会影响改造功能, 迁移后预览视频不提供改造(参数), 只有序列帧动作有改造
仍存在的共性问题: #134
- 导出:
下载改为完整序列帧, 封面不再作为fallback, 部分帧读取失败时行为:仍生成 ZIP,并记录失败帧, 不变
画布放置:
before:

after:

精选模块: 主页展示, 审核部分UI:


这两处序列帧们的加载设计为惰式的, 只有hover和单独preview才会全部加载
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/117
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
1499 lines
46 KiB
TypeScript
1499 lines
46 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
getAdminAssetReadUrl,
|
|
listAdminEditorAssets,
|
|
} from '../api/adminApiClient';
|
|
import type {
|
|
AdminEditorAssetListResponse,
|
|
AdminEditorAssetPayload,
|
|
} from '../api/adminApiTypes';
|
|
import { AdminEditorAssetQueryPage } from './AdminEditorAssetQueryPage';
|
|
|
|
vi.mock('../api/adminApiClient', () => ({
|
|
formatAdminApiError: vi.fn((error: unknown) =>
|
|
error instanceof Error ? error.message : '请求失败',
|
|
),
|
|
getAdminAssetReadUrl: vi.fn(),
|
|
isAdminApiError: vi.fn(
|
|
(error: unknown) =>
|
|
typeof error === 'object' &&
|
|
error !== null &&
|
|
'status' in error &&
|
|
typeof error.status === 'number',
|
|
),
|
|
listAdminEditorAssets: vi.fn(),
|
|
}));
|
|
|
|
interface MockIntersectionObserverController {
|
|
enter: (target: Element) => void;
|
|
isObserved: (target: Element) => boolean;
|
|
}
|
|
|
|
function installIntersectionObserverMock(): MockIntersectionObserverController {
|
|
const observed = new Map<
|
|
Element,
|
|
{
|
|
callback: IntersectionObserverCallback;
|
|
observer: IntersectionObserver;
|
|
}
|
|
>();
|
|
|
|
class MockIntersectionObserver implements IntersectionObserver {
|
|
readonly root = null;
|
|
readonly rootMargin: string;
|
|
readonly thresholds = [0];
|
|
private readonly targets = new Set<Element>();
|
|
|
|
constructor(
|
|
private readonly callback: IntersectionObserverCallback,
|
|
options: IntersectionObserverInit = {},
|
|
) {
|
|
this.rootMargin = options.rootMargin ?? '0px';
|
|
}
|
|
|
|
observe(target: Element) {
|
|
this.targets.add(target);
|
|
observed.set(target, {
|
|
callback: this.callback,
|
|
observer: this as unknown as IntersectionObserver,
|
|
});
|
|
}
|
|
|
|
unobserve(target: Element) {
|
|
this.targets.delete(target);
|
|
observed.delete(target);
|
|
}
|
|
|
|
disconnect() {
|
|
this.targets.forEach((target) => observed.delete(target));
|
|
this.targets.clear();
|
|
}
|
|
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
|
|
|
return {
|
|
enter(target) {
|
|
const record = observed.get(target);
|
|
if (!record) {
|
|
throw new Error('目标缩略图尚未进入 IntersectionObserver');
|
|
}
|
|
act(() => {
|
|
record.callback(
|
|
[
|
|
{
|
|
isIntersecting: true,
|
|
target,
|
|
} as IntersectionObserverEntry,
|
|
],
|
|
record.observer,
|
|
);
|
|
});
|
|
},
|
|
isObserved(target) {
|
|
return observed.has(target);
|
|
},
|
|
};
|
|
}
|
|
|
|
vi.mock('../components/AdminUserReferenceButton', () => ({
|
|
AdminUserReferenceButton: ({
|
|
userId,
|
|
publicUserCode,
|
|
}: {
|
|
userId?: string;
|
|
publicUserCode?: string | null;
|
|
}) => (
|
|
<button
|
|
aria-label="查看素材作者"
|
|
data-public-user-code={publicUserCode ?? undefined}
|
|
data-user-id={userId}
|
|
type="button"
|
|
/>
|
|
),
|
|
}));
|
|
|
|
const generatedAsset: AdminEditorAssetPayload = {
|
|
assetId: 'asset-1',
|
|
ownerUserId: 'user-1',
|
|
authorDisplayName: '作者昵称',
|
|
authorPublicUserCode: 'SY-00000042',
|
|
folderId: 'folder-1',
|
|
label: '角色形象 1',
|
|
assetObjectId: 'asset-object-1',
|
|
imageSrc: '/generated-character-drafts/editor/spec.png',
|
|
objectKey: 'generated-character-drafts/editor/spec.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
sourceType: 'generated',
|
|
prompt: '完整提示词内容',
|
|
actualPrompt: null,
|
|
model: 'gpt-image-2',
|
|
provider: 'character',
|
|
taskId: 'task-1',
|
|
assetKind: 'character',
|
|
generationInputs: { style: 'clay' },
|
|
sourceResourceId: 'resource-1',
|
|
thumbnailSrc: null,
|
|
generationCostMudPoints: 12,
|
|
createdAt: '2026-07-04T10:00:00Z',
|
|
updatedAt: '2026-07-04T10:00:00Z',
|
|
generator: '角色抠图',
|
|
taskGenerator: '角色生成',
|
|
taskCostMudPoints: 12,
|
|
children: [],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValue({
|
|
entries: [generatedAsset],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
|
read: {
|
|
objectKey: 'generated-character-drafts/editor/spec.png',
|
|
signedUrl:
|
|
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
function generatedAssetAt(index: number): AdminEditorAssetPayload {
|
|
const objectKey = `generated-character-drafts/editor/spec-${index}.png`;
|
|
return {
|
|
...generatedAsset,
|
|
assetId: `asset-${index}`,
|
|
label: `角色形象 ${index}`,
|
|
imageSrc: `/${objectKey}`,
|
|
objectKey,
|
|
};
|
|
}
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function thumbnailElementForLabel(label: string) {
|
|
const row = screen.getByText(label).closest('tr');
|
|
const thumbnail = row?.querySelector('.admin-asset-query-thumb');
|
|
if (!thumbnail) {
|
|
throw new Error(`未找到素材缩略图:${label}`);
|
|
}
|
|
return thumbnail;
|
|
}
|
|
|
|
test('后台素材查询展示作者昵称和陶泥号', async () => {
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
expect(await screen.findByText('作者昵称')).toBeTruthy();
|
|
expect(screen.getByText('SY-00000042')).toBeTruthy();
|
|
expect(screen.queryByText('user-1')).toBeNull();
|
|
const userButton = screen.getByRole('button', { name: '查看素材作者' });
|
|
expect(userButton.getAttribute('data-user-id')).toBe('user-1');
|
|
expect(userButton.getAttribute('data-public-user-code')).toBe('SY-00000042');
|
|
});
|
|
|
|
test('后台素材查询按用户、搜索和时间调用查询接口', async () => {
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await screen.findByRole('img', { name: '素材:角色形象 1' });
|
|
|
|
fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), {
|
|
target: { value: 'SY-00000055' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('搜索'), {
|
|
target: { value: '陶泥角色' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('开始时间'), {
|
|
target: { value: '2026-07-01' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('结束时间'), {
|
|
target: { value: '2026-07-04' },
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(listAdminEditorAssets).toHaveBeenLastCalledWith(
|
|
'admin-token',
|
|
{
|
|
ownerUserId: 'SY-00000055',
|
|
keyword: '陶泥角色',
|
|
createdAfter: '2026-07-01T00:00:00+08:00',
|
|
createdBefore: '2026-07-04T23:59:59.999+08:00',
|
|
limit: 80,
|
|
},
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
|
|
test('后台素材查询逐字输入时只请求最终筛选值', async () => {
|
|
installIntersectionObserverMock();
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await screen.findByText('角色形象 1');
|
|
vi.mocked(listAdminEditorAssets).mockClear();
|
|
vi.useFakeTimers();
|
|
const ownerInput = screen.getByLabelText('用户 ID / 陶泥号');
|
|
const values = [
|
|
'S',
|
|
'SY',
|
|
'SY-',
|
|
'SY-0',
|
|
'SY-00',
|
|
'SY-000',
|
|
'SY-0000',
|
|
'SY-00000',
|
|
'SY-000000',
|
|
'SY-0000005',
|
|
'SY-00000055',
|
|
];
|
|
for (const [index, value] of values.entries()) {
|
|
fireEvent.change(ownerInput, { target: { value } });
|
|
if (index < values.length - 1) {
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
});
|
|
expect(listAdminEditorAssets).not.toHaveBeenCalled();
|
|
}
|
|
}
|
|
|
|
expect(listAdminEditorAssets).not.toHaveBeenCalled();
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(299);
|
|
});
|
|
expect(listAdminEditorAssets).not.toHaveBeenCalled();
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
});
|
|
expect(listAdminEditorAssets).toHaveBeenCalledTimes(1);
|
|
expect(listAdminEditorAssets).toHaveBeenLastCalledWith(
|
|
'admin-token',
|
|
{
|
|
ownerUserId: 'SY-00000055',
|
|
keyword: null,
|
|
createdAfter: null,
|
|
createdBefore: null,
|
|
limit: 80,
|
|
},
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
test('后台素材查询只应用最新筛选请求的结果和加载状态', async () => {
|
|
const staleResponse = createDeferred<AdminEditorAssetListResponse>();
|
|
const currentResponse = createDeferred<AdminEditorAssetListResponse>();
|
|
vi.mocked(listAdminEditorAssets)
|
|
.mockResolvedValueOnce({
|
|
entries: [generatedAsset],
|
|
nextCursor: null,
|
|
})
|
|
.mockImplementationOnce(() => staleResponse.promise)
|
|
.mockImplementationOnce(() => currentResponse.promise);
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await screen.findByText('角色形象 1');
|
|
fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), {
|
|
target: { value: 'user-stale' },
|
|
});
|
|
await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(2));
|
|
fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), {
|
|
target: { value: 'SY-00000055' },
|
|
});
|
|
await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(3));
|
|
|
|
await act(async () => {
|
|
staleResponse.resolve({
|
|
entries: [
|
|
{ ...generatedAsset, assetId: 'asset-stale', label: '旧筛选素材' },
|
|
],
|
|
nextCursor: 'cursor-stale',
|
|
});
|
|
await staleResponse.promise;
|
|
});
|
|
|
|
expect(screen.queryByText('旧筛选素材')).toBeNull();
|
|
expect(screen.getByRole('button', { name: '刷新中' })).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
currentResponse.resolve({
|
|
entries: [
|
|
{ ...generatedAsset, assetId: 'asset-current', label: '当前筛选素材' },
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
await currentResponse.promise;
|
|
});
|
|
|
|
expect(await screen.findByText('当前筛选素材')).toBeTruthy();
|
|
expect(screen.queryByText('旧筛选素材')).toBeNull();
|
|
expect(screen.getByRole('button', { name: '刷新' })).toBeTruthy();
|
|
});
|
|
|
|
test('后台素材查询筛选失败时隐藏旧结果且不展示空态', async () => {
|
|
vi.mocked(listAdminEditorAssets)
|
|
.mockResolvedValueOnce({
|
|
entries: [generatedAssetAt(1)],
|
|
nextCursor: 'cursor-old',
|
|
})
|
|
.mockRejectedValueOnce(new Error('筛选失败'));
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
expect(await screen.findByText('角色形象 1')).toBeTruthy();
|
|
expect(screen.getByRole('button', { name: '读取更多' })).toBeTruthy();
|
|
|
|
fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), {
|
|
target: { value: 'SY-00000055' },
|
|
});
|
|
|
|
expect(screen.queryByText('角色形象 1')).toBeNull();
|
|
expect(screen.queryByRole('button', { name: '读取更多' })).toBeNull();
|
|
expect(await screen.findByText('筛选失败')).toBeTruthy();
|
|
expect(screen.queryByText('角色形象 1')).toBeNull();
|
|
expect(screen.queryByRole('button', { name: '读取更多' })).toBeNull();
|
|
expect(screen.queryByText('暂无生成素材')).toBeNull();
|
|
});
|
|
|
|
test('后台素材查询取消旧筛选请求时不展示错误', async () => {
|
|
installIntersectionObserverMock();
|
|
let staleSignal: AbortSignal | undefined;
|
|
vi.mocked(listAdminEditorAssets)
|
|
.mockResolvedValueOnce({
|
|
entries: [generatedAsset],
|
|
nextCursor: null,
|
|
})
|
|
.mockImplementationOnce((_token, _query, signal) => {
|
|
staleSignal = signal;
|
|
return new Promise<AdminEditorAssetListResponse>((_resolve, reject) => {
|
|
signal?.addEventListener(
|
|
'abort',
|
|
() => reject(new DOMException('请求已取消', 'AbortError')),
|
|
{ once: true },
|
|
);
|
|
});
|
|
})
|
|
.mockResolvedValueOnce({
|
|
entries: [
|
|
{ ...generatedAsset, assetId: 'asset-current', label: '最新筛选素材' },
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await screen.findByText('角色形象 1');
|
|
fireEvent.change(screen.getByLabelText('搜索'), {
|
|
target: { value: '旧筛选' },
|
|
});
|
|
await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(2));
|
|
expect(staleSignal?.aborted).toBe(false);
|
|
vi.useFakeTimers();
|
|
|
|
fireEvent.change(screen.getByLabelText('搜索'), {
|
|
target: { value: '最新筛选' },
|
|
});
|
|
expect(staleSignal?.aborted).toBe(true);
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(screen.queryByRole('status')).toBeNull();
|
|
expect(listAdminEditorAssets).toHaveBeenCalledTimes(2);
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(299);
|
|
});
|
|
expect(listAdminEditorAssets).toHaveBeenCalledTimes(2);
|
|
expect(screen.queryByRole('status')).toBeNull();
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
});
|
|
expect(listAdminEditorAssets).toHaveBeenCalledTimes(3);
|
|
expect(screen.getByText('最新筛选素材')).toBeTruthy();
|
|
expect(screen.queryByRole('status')).toBeNull();
|
|
});
|
|
|
|
test('后台素材查询手动刷新失败时保留当前结果和游标', async () => {
|
|
const refreshResponse = createDeferred<AdminEditorAssetListResponse>();
|
|
vi.mocked(listAdminEditorAssets)
|
|
.mockResolvedValueOnce({
|
|
entries: [generatedAssetAt(1)],
|
|
nextCursor: 'cursor-1',
|
|
})
|
|
.mockImplementationOnce(() => refreshResponse.promise);
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
expect(await screen.findByText('角色形象 1')).toBeTruthy();
|
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
expect(screen.getByText('角色形象 1')).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('button', { name: '读取更多' }).hasAttribute('disabled'),
|
|
).toBe(true);
|
|
|
|
await act(async () => {
|
|
refreshResponse.reject(new Error('刷新失败'));
|
|
try {
|
|
await refreshResponse.promise;
|
|
} catch {
|
|
// The page owns the rejected request.
|
|
}
|
|
});
|
|
|
|
expect(await screen.findByText('刷新失败')).toBeTruthy();
|
|
expect(screen.getByText('角色形象 1')).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('button', { name: '读取更多' }).hasAttribute('disabled'),
|
|
).toBe(false);
|
|
expect(screen.queryByText('暂无生成素材')).toBeNull();
|
|
});
|
|
|
|
test('后台素材查询读取更多进行中手动刷新失败时保留原页', async () => {
|
|
const staleLoadMoreResponse = createDeferred<AdminEditorAssetListResponse>();
|
|
const refreshResponse = createDeferred<AdminEditorAssetListResponse>();
|
|
let staleLoadMoreSignal: AbortSignal | undefined;
|
|
vi.mocked(listAdminEditorAssets)
|
|
.mockResolvedValueOnce({
|
|
entries: [generatedAssetAt(1)],
|
|
nextCursor: 'cursor-1',
|
|
})
|
|
.mockImplementationOnce((_token, _query, signal) => {
|
|
staleLoadMoreSignal = signal;
|
|
return staleLoadMoreResponse.promise;
|
|
})
|
|
.mockImplementationOnce(() => refreshResponse.promise)
|
|
.mockResolvedValueOnce({
|
|
entries: [generatedAssetAt(81)],
|
|
nextCursor: null,
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '读取更多' }));
|
|
await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(2));
|
|
expect(staleLoadMoreSignal?.aborted).toBe(false);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
expect(staleLoadMoreSignal?.aborted).toBe(true);
|
|
expect(listAdminEditorAssets).toHaveBeenCalledTimes(3);
|
|
expect(screen.getByText('角色形象 1')).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('button', { name: '读取更多' }).hasAttribute('disabled'),
|
|
).toBe(true);
|
|
|
|
await act(async () => {
|
|
refreshResponse.reject(new Error('刷新失败'));
|
|
try {
|
|
await refreshResponse.promise;
|
|
} catch {
|
|
// The page owns the rejected request.
|
|
}
|
|
});
|
|
|
|
expect(await screen.findByText('刷新失败')).toBeTruthy();
|
|
expect(screen.getByText('角色形象 1')).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('button', { name: '读取更多' }).hasAttribute('disabled'),
|
|
).toBe(false);
|
|
|
|
await act(async () => {
|
|
staleLoadMoreResponse.resolve({
|
|
entries: [
|
|
{ ...generatedAsset, assetId: 'asset-stale', label: '旧分页素材' },
|
|
],
|
|
nextCursor: 'cursor-stale',
|
|
});
|
|
await staleLoadMoreResponse.promise;
|
|
});
|
|
expect(screen.queryByText('旧分页素材')).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '读取更多' }));
|
|
expect(await screen.findByText('角色形象 81')).toBeTruthy();
|
|
expect(listAdminEditorAssets).toHaveBeenLastCalledWith(
|
|
'admin-token',
|
|
{
|
|
ownerUserId: null,
|
|
keyword: null,
|
|
createdAfter: null,
|
|
createdBefore: null,
|
|
limit: 80,
|
|
cursor: 'cursor-1',
|
|
},
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
test('后台素材查询不展示分类筛选和分类列', async () => {
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
expect(await screen.findByText('角色形象 1')).toBeTruthy();
|
|
expect(screen.queryByLabelText('分类')).toBeNull();
|
|
expect(screen.queryByRole('columnheader', { name: '分类' })).toBeNull();
|
|
});
|
|
|
|
test('后台素材查询将中间产物按真实类型逐行收纳在最终产物下', async () => {
|
|
const providerSource: AdminEditorAssetPayload = {
|
|
...generatedAsset,
|
|
assetId: 'asset-source',
|
|
label: '角色形象生成原图',
|
|
objectKey:
|
|
'generated-character-drafts/editor/character-images/provider-source-task-1.png',
|
|
imageSrc:
|
|
'/generated-character-drafts/editor/character-images/provider-source-task-1.png',
|
|
assetKind: 'character',
|
|
generator: '角色生图',
|
|
taskGenerator: '角色生成',
|
|
generationCostMudPoints: 12,
|
|
taskCostMudPoints: 12,
|
|
children: [],
|
|
};
|
|
const postprocessOutput: AdminEditorAssetPayload = {
|
|
...generatedAsset,
|
|
assetId: 'asset-postprocess',
|
|
label: '角色抠图阶段产物',
|
|
objectKey:
|
|
'generated-character-drafts/editor/character-images/background-removal-task-1.png',
|
|
imageSrc:
|
|
'/generated-character-drafts/editor/character-images/background-removal-task-1.png',
|
|
assetKind: 'character',
|
|
generator: '角色抠图',
|
|
taskGenerator: '角色生成',
|
|
generationCostMudPoints: 0,
|
|
taskCostMudPoints: 0,
|
|
children: [],
|
|
};
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
generationCostMudPoints: 0,
|
|
taskCostMudPoints: 12,
|
|
children: [providerSource, postprocessOutput],
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
const finalRow = (await screen.findByText('角色形象 1')).closest('tr');
|
|
expect(finalRow).not.toBeNull();
|
|
expect(within(finalRow!).getByText('12 泥点')).toBeTruthy();
|
|
expect(screen.queryByText('角色形象生成原图')).toBeNull();
|
|
expect(screen.queryByText('角色抠图阶段产物')).toBeNull();
|
|
|
|
fireEvent.click(
|
|
within(finalRow!).getByRole('button', { name: '展开中间产物' }),
|
|
);
|
|
|
|
const sourceRow = (await screen.findByText('角色形象生成原图')).closest('tr');
|
|
const postprocessRow = screen.getByText('角色抠图阶段产物').closest('tr');
|
|
expect(sourceRow).not.toBeNull();
|
|
expect(postprocessRow).not.toBeNull();
|
|
expect(within(sourceRow!).getByText('角色生图')).toBeTruthy();
|
|
expect(within(sourceRow!).getByText('12 泥点')).toBeTruthy();
|
|
expect(within(postprocessRow!).getByText('角色抠图')).toBeTruthy();
|
|
expect(
|
|
within(postprocessRow!).getByText('本阶段不额外扣费'),
|
|
).toBeTruthy();
|
|
|
|
fireEvent.click(within(sourceRow!).getByRole('button', { name: '详情' }));
|
|
const detail = await screen.findByRole('dialog', { name: '素材详情' });
|
|
expect(within(detail).getByText('角色生图')).toBeTruthy();
|
|
expect(within(detail).getByText('12 泥点')).toBeTruthy();
|
|
});
|
|
|
|
test('后台素材查询缩略图使用 objectKey 换签后展示', async () => {
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
const image = await screen.findByRole('img', { name: '素材:角色形象 1' });
|
|
|
|
await waitFor(() => {
|
|
expect(image.getAttribute('src')).toBe(
|
|
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-character-drafts/editor/spec.png',
|
|
expireSeconds: 300,
|
|
});
|
|
});
|
|
|
|
test('后台素材查询只为进入可视区域的缩略图换签并能展示后续行', async () => {
|
|
const observer = installIntersectionObserverMock();
|
|
const entries = [
|
|
generatedAssetAt(1),
|
|
generatedAssetAt(2),
|
|
generatedAssetAt(3),
|
|
];
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries,
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockImplementation(
|
|
async (_token, request) => ({
|
|
read: {
|
|
objectKey: request.objectKey ?? '',
|
|
signedUrl: `https://signed.example.com/${request.objectKey}`,
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
expect(await screen.findByText('角色形象 3')).toBeTruthy();
|
|
const firstThumbnail = thumbnailElementForLabel('角色形象 1');
|
|
const laterThumbnail = thumbnailElementForLabel('角色形象 3');
|
|
await waitFor(() => {
|
|
expect(observer.isObserved(firstThumbnail)).toBe(true);
|
|
expect(observer.isObserved(laterThumbnail)).toBe(true);
|
|
});
|
|
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
|
|
|
|
observer.enter(laterThumbnail);
|
|
|
|
const laterImage = await screen.findByRole('img', {
|
|
name: '素材:角色形象 3',
|
|
});
|
|
expect(laterImage.getAttribute('src')).toBe(
|
|
'https://signed.example.com/generated-character-drafts/editor/spec-3.png',
|
|
);
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(1);
|
|
expect(getAdminAssetReadUrl).toHaveBeenLastCalledWith('admin-token', {
|
|
objectKey: 'generated-character-drafts/editor/spec-3.png',
|
|
expireSeconds: 300,
|
|
});
|
|
|
|
observer.enter(firstThumbnail);
|
|
expect(
|
|
await screen.findByRole('img', { name: '素材:角色形象 1' }),
|
|
).toBeTruthy();
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('后台素材查询为大量同时可见的缩略图持续错峰换签', async () => {
|
|
vi.useFakeTimers();
|
|
const observer = installIntersectionObserverMock();
|
|
const entries = Array.from({ length: 105 }, (_, index) =>
|
|
generatedAssetAt(index + 1),
|
|
);
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries,
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockImplementation(
|
|
async (_token, request) => ({
|
|
read: {
|
|
objectKey: request.objectKey ?? '',
|
|
signedUrl: `https://signed.example.com/${request.objectKey}`,
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const thumbnails = entries.map((entry) =>
|
|
thumbnailElementForLabel(entry.label),
|
|
);
|
|
thumbnails.forEach((thumbnail) => {
|
|
expect(observer.isObserved(thumbnail)).toBe(true);
|
|
observer.enter(thumbnail);
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(39);
|
|
});
|
|
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(3_961);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(100);
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
|
|
});
|
|
|
|
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
|
|
const observer = installIntersectionObserverMock();
|
|
const firstEntry = generatedAssetAt(1);
|
|
const nextEntry = generatedAssetAt(81);
|
|
vi.mocked(listAdminEditorAssets)
|
|
.mockResolvedValueOnce({
|
|
entries: [firstEntry],
|
|
nextCursor: 'cursor-1',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
entries: [nextEntry],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockImplementation(
|
|
async (_token, request) => ({
|
|
read: {
|
|
objectKey: request.objectKey ?? '',
|
|
signedUrl: `https://signed.example.com/${request.objectKey}`,
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '读取更多' }));
|
|
expect(await screen.findByText('角色形象 81')).toBeTruthy();
|
|
const nextThumbnail = thumbnailElementForLabel('角色形象 81');
|
|
await waitFor(() => expect(observer.isObserved(nextThumbnail)).toBe(true));
|
|
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
|
|
|
|
observer.enter(nextThumbnail);
|
|
|
|
expect(
|
|
await screen.findByRole('img', { name: '素材:角色形象 81' }),
|
|
).toBeTruthy();
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-character-drafts/editor/spec-81.png',
|
|
expireSeconds: 300,
|
|
});
|
|
expect(listAdminEditorAssets).toHaveBeenLastCalledWith(
|
|
'admin-token',
|
|
{
|
|
ownerUserId: null,
|
|
keyword: null,
|
|
createdAfter: null,
|
|
createdBefore: null,
|
|
limit: 80,
|
|
cursor: 'cursor-1',
|
|
},
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
test('后台素材查询筛选刷新后忽略旧读取更多响应和游标', async () => {
|
|
const staleLoadMoreResponse = createDeferred<AdminEditorAssetListResponse>();
|
|
const refreshedResponse = createDeferred<AdminEditorAssetListResponse>();
|
|
let staleLoadMoreSignal: AbortSignal | undefined;
|
|
vi.mocked(listAdminEditorAssets)
|
|
.mockResolvedValueOnce({
|
|
entries: [generatedAssetAt(1)],
|
|
nextCursor: 'cursor-old',
|
|
})
|
|
.mockImplementationOnce((_token, _query, signal) => {
|
|
staleLoadMoreSignal = signal;
|
|
return staleLoadMoreResponse.promise;
|
|
})
|
|
.mockImplementationOnce(() => refreshedResponse.promise)
|
|
.mockResolvedValueOnce({ entries: [], nextCursor: null });
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '读取更多' }));
|
|
await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(2));
|
|
fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), {
|
|
target: { value: 'SY-00000055' },
|
|
});
|
|
expect(staleLoadMoreSignal?.aborted).toBe(true);
|
|
await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(3));
|
|
await waitFor(() =>
|
|
expect(screen.queryByRole('button', { name: '读取更多' })).toBeNull(),
|
|
);
|
|
|
|
await act(async () => {
|
|
refreshedResponse.resolve({
|
|
entries: [
|
|
{ ...generatedAsset, assetId: 'asset-current', label: '当前用户素材' },
|
|
],
|
|
nextCursor: 'cursor-current',
|
|
});
|
|
await refreshedResponse.promise;
|
|
});
|
|
expect(await screen.findByText('当前用户素材')).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
staleLoadMoreResponse.resolve({
|
|
entries: [
|
|
{ ...generatedAsset, assetId: 'asset-stale', label: '旧分页素材' },
|
|
],
|
|
nextCursor: 'cursor-stale',
|
|
});
|
|
await staleLoadMoreResponse.promise;
|
|
});
|
|
|
|
expect(screen.queryByText('旧分页素材')).toBeNull();
|
|
fireEvent.click(screen.getByRole('button', { name: '读取更多' }));
|
|
await waitFor(() => {
|
|
expect(listAdminEditorAssets).toHaveBeenLastCalledWith(
|
|
'admin-token',
|
|
{
|
|
ownerUserId: 'SY-00000055',
|
|
keyword: null,
|
|
createdAfter: null,
|
|
createdBefore: null,
|
|
limit: 80,
|
|
cursor: 'cursor-current',
|
|
},
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
|
|
test('后台素材查询缩略图换签被限流后有限重试并恢复', async () => {
|
|
const observer = installIntersectionObserverMock();
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [generatedAsset],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl)
|
|
.mockRejectedValueOnce({ status: 429 })
|
|
.mockResolvedValueOnce({
|
|
read: {
|
|
objectKey: generatedAsset.objectKey ?? '',
|
|
signedUrl:
|
|
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
expect(await screen.findByText('角色形象 1')).toBeTruthy();
|
|
const thumbnail = thumbnailElementForLabel('角色形象 1');
|
|
await waitFor(() => expect(observer.isObserved(thumbnail)).toBe(true));
|
|
observer.enter(thumbnail);
|
|
|
|
const image = await screen.findByRole(
|
|
'img',
|
|
{ name: '素材:角色形象 1' },
|
|
{ timeout: 2_000 },
|
|
);
|
|
expect(image.getAttribute('src')).toBe(
|
|
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
|
);
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('后台素材查询缩略图卸载后停止限流重试', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date());
|
|
const observer = installIntersectionObserverMock();
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [generatedAsset],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockRejectedValue({ status: 429 });
|
|
|
|
const { unmount } = render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
const thumbnail = thumbnailElementForLabel('角色形象 1');
|
|
expect(observer.isObserved(thumbnail)).toBe(true);
|
|
observer.enter(thumbnail);
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(1);
|
|
|
|
unmount();
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('后台素材查询将无 objectKey 的绝对 OSS 图片地址换签后展示', async () => {
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
imageSrc:
|
|
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-character-drafts/editor/absolute.png?x-oss-process=image/resize,w_320',
|
|
objectKey: null,
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
|
read: {
|
|
objectKey: 'generated-character-drafts/editor/absolute.png',
|
|
signedUrl: 'https://signed.example.com/absolute.png',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
const image = await screen.findByRole('img', { name: '素材:角色形象 1' });
|
|
await waitFor(() => {
|
|
expect(image.getAttribute('src')).toBe(
|
|
'https://signed.example.com/absolute.png',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
legacyPublicPath: '/generated-character-drafts/editor/absolute.png',
|
|
expireSeconds: 300,
|
|
});
|
|
});
|
|
|
|
test('后台素材查询为独立缩略图路径换签而不误用原图 objectKey', async () => {
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
thumbnailSrc: '/generated-character-drafts/editor/spec-thumbnail.png',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
|
read: {
|
|
objectKey: 'generated-character-drafts/editor/spec-thumbnail.png',
|
|
signedUrl: 'https://signed.example.com/spec-thumbnail.png',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
const image = await screen.findByRole('img', { name: '素材:角色形象 1' });
|
|
expect(image.getAttribute('src')).toBe(
|
|
'https://signed.example.com/spec-thumbnail.png',
|
|
);
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
legacyPublicPath: '/generated-character-drafts/editor/spec-thumbnail.png',
|
|
expireSeconds: 300,
|
|
});
|
|
expect(getAdminAssetReadUrl).not.toHaveBeenCalledWith('admin-token', {
|
|
objectKey: generatedAsset.objectKey,
|
|
expireSeconds: 300,
|
|
});
|
|
});
|
|
|
|
test('后台素材查询点击图片缩略图可打开放大预览', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await user.click(
|
|
await screen.findByRole('button', { name: '素材:角色形象 1' }),
|
|
);
|
|
|
|
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
|
|
const image = await within(dialog).findByRole('img', {
|
|
name: '图片预览:角色形象 1',
|
|
});
|
|
await waitFor(() => {
|
|
expect(image.getAttribute('src')).toBe(
|
|
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
|
);
|
|
});
|
|
});
|
|
|
|
test('后台素材查询在现有预览弹窗播放完整角色动作序列', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
assetId: 'asset-character-animation-1',
|
|
label: '角色动作首帧',
|
|
imageSrc: '/generated-animations/editor/source-1/task-1/frame00.png',
|
|
objectKey: 'generated-animations/editor/source-1/task-1/frame00.png',
|
|
assetKind: 'character-animation',
|
|
thumbnailSrc:
|
|
'/generated-animations/editor/source-1/task-1/frame00.png',
|
|
imageSequenceFrames: [
|
|
{
|
|
imageSrc:
|
|
'/generated-animations/editor/source-1/task-1/frame00.png',
|
|
objectKey:
|
|
'generated-animations/editor/source-1/task-1/frame00.png',
|
|
width: 192,
|
|
height: 256,
|
|
},
|
|
{
|
|
imageSrc:
|
|
'/generated-animations/editor/source-1/task-1/frame01.png',
|
|
objectKey:
|
|
'generated-animations/editor/source-1/task-1/frame01.png',
|
|
width: 192,
|
|
height: 256,
|
|
},
|
|
{
|
|
imageSrc:
|
|
'/generated-animations/editor/source-1/task-1/frame02.png',
|
|
objectKey:
|
|
'generated-animations/editor/source-1/task-1/frame02.png',
|
|
width: 192,
|
|
height: 256,
|
|
},
|
|
{
|
|
imageSrc:
|
|
'/generated-animations/editor/source-1/task-1/frame03.png',
|
|
objectKey:
|
|
'generated-animations/editor/source-1/task-1/frame03.png',
|
|
width: 192,
|
|
height: 256,
|
|
},
|
|
{
|
|
imageSrc:
|
|
'/generated-animations/editor/source-1/task-1/frame04.png',
|
|
objectKey:
|
|
'generated-animations/editor/source-1/task-1/frame04.png',
|
|
width: 192,
|
|
height: 256,
|
|
},
|
|
],
|
|
imageSequenceDurationMs: 250,
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockImplementation(
|
|
async (_token, request) => {
|
|
const objectKey = request.objectKey ?? undefined;
|
|
return {
|
|
read: {
|
|
objectKey,
|
|
signedUrl: `https://signed.example.com/${objectKey ?? ''}`,
|
|
expiresAt: '2099-01-01T00:05:00Z',
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await user.click(
|
|
await screen.findByRole('button', { name: '素材:角色动作首帧' }),
|
|
);
|
|
|
|
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
|
|
await user.click(
|
|
await within(dialog).findByRole('button', {
|
|
name: '暂停角色动作',
|
|
}),
|
|
);
|
|
await waitFor(() => {
|
|
expect(
|
|
dialog.querySelectorAll('.admin-asset-query-sequence-frame'),
|
|
).toHaveLength(3);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-animations/editor/source-1/task-1/frame00.png',
|
|
expireSeconds: 300,
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-animations/editor/source-1/task-1/frame01.png',
|
|
expireSeconds: 300,
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-animations/editor/source-1/task-1/frame02.png',
|
|
expireSeconds: 300,
|
|
});
|
|
expect(getAdminAssetReadUrl).not.toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-animations/editor/source-1/task-1/frame03.png',
|
|
expireSeconds: 300,
|
|
});
|
|
|
|
const failedFrame = dialog.querySelector(
|
|
'.admin-asset-query-sequence-frame',
|
|
) as HTMLImageElement;
|
|
fireEvent.error(failedFrame);
|
|
expect(await within(dialog).findByText('1 帧加载失败')).toBeTruthy();
|
|
await waitFor(() => {
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-animations/editor/source-1/task-1/frame03.png',
|
|
expireSeconds: 300,
|
|
});
|
|
});
|
|
const callsBeforeRetry = vi.mocked(getAdminAssetReadUrl).mock.calls.length;
|
|
await user.click(
|
|
within(dialog).getByRole('button', { name: '重试失败帧' }),
|
|
);
|
|
await waitFor(() => {
|
|
expect(vi.mocked(getAdminAssetReadUrl).mock.calls.length).toBeGreaterThan(
|
|
callsBeforeRetry,
|
|
);
|
|
});
|
|
expect(
|
|
within(dialog).getByRole('button', { name: '暂停角色动作' }),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
test('后台素材查询音频素材使用统一封面缩略图', async () => {
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
assetId: 'asset-audio-1',
|
|
label: '胜利音效',
|
|
imageSrc: '/generated-editor-audios/sfx.mp3',
|
|
objectKey: 'generated-editor-audios/sfx.mp3',
|
|
assetKind: 'sound-effect',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
const image = await screen.findByRole('img', { name: '素材:胜利音效' });
|
|
expect(image.getAttribute('src')).toBe(
|
|
'/creation-home/audio-asset-cover.png',
|
|
);
|
|
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('后台素材查询点击音频缩略图后签名音频并展示播放器', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
assetId: 'asset-audio-1',
|
|
label: '胜利音效',
|
|
imageSrc: '/generated-editor-audios/sfx.mp3',
|
|
objectKey: 'generated-editor-audios/sfx.mp3',
|
|
assetKind: 'sound-effect',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
|
read: {
|
|
objectKey: 'generated-editor-audios/sfx.mp3',
|
|
signedUrl: 'https://signed.example.com/generated-editor-audios/sfx.mp3',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
await user.click(
|
|
await screen.findByRole('button', { name: '素材:胜利音效' }),
|
|
);
|
|
|
|
const player = await screen.findByLabelText('音频预览:胜利音效');
|
|
await waitFor(() => {
|
|
expect(player.getAttribute('src')).toBe(
|
|
'https://signed.example.com/generated-editor-audios/sfx.mp3',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-editor-audios/sfx.mp3',
|
|
expireSeconds: 300,
|
|
});
|
|
});
|
|
|
|
test('后台素材查询视频缩略图使用封面并在预览中播放视频', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
assetId: 'asset-video-1',
|
|
label: '生成视频 1',
|
|
imageSrc: '/generated-editor-videos/task-1/preview.mp4',
|
|
objectKey:
|
|
'generated-character-drafts/editor-videos/task-1/preview.mp4',
|
|
assetKind: 'editor_video',
|
|
thumbnailSrc: '/generated-editor-videos/task-1/cover.png',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockImplementation(
|
|
async (_token, request) => {
|
|
if ('legacyPublicPath' in request) {
|
|
return {
|
|
read: {
|
|
objectKey: 'generated-editor-videos/task-1/cover.png',
|
|
signedUrl: 'https://signed.example.com/video-cover.png',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
};
|
|
}
|
|
const objectKey = request.objectKey ?? '';
|
|
return {
|
|
read: {
|
|
objectKey,
|
|
signedUrl: objectKey.endsWith('.mp4')
|
|
? 'https://signed.example.com/video-preview.mp4'
|
|
: 'https://signed.example.com/video-cover-by-key.png',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
const image = await screen.findByRole('img', { name: '素材:生成视频 1' });
|
|
await waitFor(() => {
|
|
expect(image.getAttribute('src')).toBe(
|
|
'https://signed.example.com/video-cover.png',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
legacyPublicPath: '/generated-editor-videos/task-1/cover.png',
|
|
expireSeconds: 300,
|
|
});
|
|
expect(getAdminAssetReadUrl).not.toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-character-drafts/editor-videos/task-1/preview.mp4',
|
|
expireSeconds: 300,
|
|
});
|
|
|
|
vi.mocked(getAdminAssetReadUrl).mockClear();
|
|
await user.click(screen.getByRole('button', { name: '素材:生成视频 1' }));
|
|
|
|
const video = await screen.findByLabelText('视频预览:生成视频 1');
|
|
await waitFor(() => {
|
|
expect(video.getAttribute('src')).toBe(
|
|
'https://signed.example.com/video-preview.mp4',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
objectKey: 'generated-character-drafts/editor-videos/task-1/preview.mp4',
|
|
expireSeconds: 300,
|
|
});
|
|
});
|
|
|
|
test('后台素材查询将无 objectKey 的绝对 OSS 视频和封面分别换签', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
assetId: 'asset-video-2',
|
|
label: '生成视频 2',
|
|
imageSrc:
|
|
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-editor-videos/task-2/preview.mp4?versionId=video',
|
|
objectKey: null,
|
|
assetKind: 'editor_video',
|
|
thumbnailSrc:
|
|
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-editor-videos/task-2/cover.png?versionId=poster',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
vi.mocked(getAdminAssetReadUrl).mockImplementation(
|
|
async (_token, request) => {
|
|
const legacyPublicPath = request.legacyPublicPath ?? '';
|
|
return {
|
|
read: {
|
|
objectKey: legacyPublicPath.replace(/^\//u, ''),
|
|
signedUrl: legacyPublicPath.endsWith('/cover.png')
|
|
? 'https://signed.example.com/video-2-cover.png'
|
|
: 'https://signed.example.com/video-2-preview.mp4',
|
|
expiresAt: '2026-07-04T11:00:00Z',
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
const thumbnail = await screen.findByRole('img', {
|
|
name: '素材:生成视频 2',
|
|
});
|
|
await waitFor(() => {
|
|
expect(thumbnail.getAttribute('src')).toBe(
|
|
'https://signed.example.com/video-2-cover.png',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
legacyPublicPath: '/generated-editor-videos/task-2/cover.png',
|
|
expireSeconds: 300,
|
|
});
|
|
|
|
vi.mocked(getAdminAssetReadUrl).mockClear();
|
|
await user.click(screen.getByRole('button', { name: '素材:生成视频 2' }));
|
|
|
|
const video = await screen.findByLabelText('视频预览:生成视频 2');
|
|
await waitFor(() => {
|
|
expect(video.getAttribute('src')).toBe(
|
|
'https://signed.example.com/video-2-preview.mp4',
|
|
);
|
|
expect(video.getAttribute('poster')).toBe(
|
|
'https://signed.example.com/video-2-cover.png',
|
|
);
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
legacyPublicPath: '/generated-editor-videos/task-2/preview.mp4',
|
|
expireSeconds: 300,
|
|
});
|
|
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
|
legacyPublicPath: '/generated-editor-videos/task-2/cover.png',
|
|
expireSeconds: 300,
|
|
});
|
|
});
|
|
|
|
test('后台素材查询格式化微秒时间文本', async () => {
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
createdAt: '1783231493.573727Z',
|
|
updatedAt: '1783231493.573727Z',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
expect(await screen.findByText('角色形象 1')).toBeTruthy();
|
|
expect(screen.queryByText('1783231493.573727Z')).toBeNull();
|
|
});
|
|
|
|
test('后台素材查询可查看素材详情', async () => {
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '详情' }));
|
|
|
|
const dialog = screen.getByRole('dialog', { name: '素材详情' });
|
|
expect(within(dialog).getByText('asset-1')).toBeTruthy();
|
|
expect(within(dialog).getByText('1024 x 1024')).toBeTruthy();
|
|
expect(within(dialog).getByText('12 泥点')).toBeTruthy();
|
|
expect(within(dialog).getByText(/"style": "clay"/u)).toBeTruthy();
|
|
});
|
|
|
|
test('后台素材详情分别展示真实操作和归组 Task ID', async () => {
|
|
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
...generatedAsset,
|
|
taskId: 'editor-atlas-split-operation-1',
|
|
groupTaskId: 'vector-engine-source-task-1',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '详情' }));
|
|
|
|
const dialog = screen.getByRole('dialog', { name: '素材详情' });
|
|
expect(within(dialog).getByText('editor-atlas-split-operation-1')).toBeTruthy();
|
|
expect(within(dialog).getByText('vector-engine-source-task-1')).toBeTruthy();
|
|
});
|
|
|
|
test('后台素材查询可打开弹窗查看完整提示词', async () => {
|
|
render(
|
|
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '完整提示词内容' }),
|
|
);
|
|
|
|
const dialog = screen.getByRole('dialog', { name: '完整提示词' });
|
|
expect(within(dialog).getByText('完整提示词内容')).toBeTruthy();
|
|
|
|
fireEvent.click(
|
|
within(dialog).getByRole('button', { name: '关闭完整提示词' }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('dialog', { name: '完整提示词' })).toBeNull();
|
|
});
|
|
});
|