/* @vitest-environment jsdom */

import {
  act,
  fireEvent,
  render,
  screen,
  waitFor,
  within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import JSZip from 'jszip';
import type { ContextType } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { ApiClientError } from '../../services/apiClient';
import { AuthUiContext } from '../auth/AuthUiContext';
import { ImageCanvasEditorView } from './ImageCanvasEditorView';

const generateEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
const editEditorImageMock = vi.hoisted(() => vi.fn());
const createEditorAssetMock = vi.hoisted(() => vi.fn());
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const updateEditorAssetMock = vi.hoisted(() => vi.fn());
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const deleteEditorAssetMock = vi.hoisted(() => vi.fn());
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());

type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;

function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
  return {
    user: null,
    canAccessProtectedData: false,
    openLoginModal: vi.fn(),
    requireAuth: vi.fn((action: () => void) => action()),
    openSettingsModal: vi.fn(),
    openAccountModal: vi.fn(),
    setCurrentUser: vi.fn(),
    logout: vi.fn(),
    musicVolume: 0.5,
    setMusicVolume: vi.fn(),
    platformTheme: 'light',
    setPlatformTheme: vi.fn(),
    isHydratingSettings: false,
    isPersistingSettings: false,
    settingsError: null,
    ...overrides,
  };
}

const defaultEditorProjectResources = [
  {
    resourceId: 'resource-puzzle',
    projectId: 'editor-project-default',
    imageSrc: '/creation-type-references/puzzle.webp',
    width: 640,
    height: 640,
    sourceType: 'uploaded',
  },
  {
    resourceId: 'resource-big-fish',
    projectId: 'editor-project-default',
    imageSrc: '/creation-type-references/big-fish.webp',
    width: 720,
    height: 405,
    sourceType: 'uploaded',
  },
];

const defaultEditorProjectLayers = [
  {
    layerId: 'layer-puzzle',
    resourceId: 'resource-puzzle',
    title: '拼图素材',
    x: 470,
    y: 300,
    width: 640,
    height: 640,
    originalWidth: 640,
    originalHeight: 640,
    zIndex: 1,
    sourceType: 'uploaded',
  },
  {
    layerId: 'layer-big-fish',
    resourceId: 'resource-big-fish',
    title: '大鱼素材',
    x: 930,
    y: 360,
    width: 720,
    height: 405,
    originalWidth: 720,
    originalHeight: 405,
    zIndex: 2,
    sourceType: 'uploaded',
  },
];

const defaultEditorAssetLibraryAssets = [
  {
    assetId: 'asset-puzzle',
    folderId: 'project',
    label: '拼图素材',
    imageSrc: '/creation-type-references/puzzle.webp',
    width: 640,
    height: 640,
    sourceType: 'uploaded',
  },
  {
    assetId: 'asset-match3d',
    folderId: 'project',
    label: '抓大鹅素材',
    imageSrc: '/creation-type-references/match3d.webp',
    width: 640,
    height: 640,
    sourceType: 'uploaded',
  },
  {
    assetId: 'asset-big-fish',
    folderId: 'project',
    label: '大鱼素材',
    imageSrc: '/creation-type-references/big-fish.webp',
    width: 720,
    height: 405,
    sourceType: 'uploaded',
  },
  {
    assetId: 'asset-bark-battle',
    folderId: 'project',
    label: '声浪素材',
    imageSrc: '/creation-type-references/bark-battle.webp',
    width: 640,
    height: 900,
    sourceType: 'uploaded',
  },
  {
    assetId: 'asset-visual-novel',
    folderId: 'project',
    label: '视觉小说素材',
    imageSrc: '/creation-type-references/visual-novel.webp',
    width: 720,
    height: 405,
    sourceType: 'uploaded',
  },
];

vi.mock('../../services/image-editor/editorProjectClient', async () => {
  const actual = await vi.importActual<
    typeof import('../../services/image-editor/editorProjectClient')
  >('../../services/image-editor/editorProjectClient');
  return {
    ...actual,
    editEditorImage: editEditorImageMock,
    createEditorAsset: createEditorAssetMock,
    createEditorAssetFolder: createEditorAssetFolderMock,
    createEditorProjectResource: createEditorProjectResourceMock,
    deleteEditorAsset: deleteEditorAssetMock,
    deleteEditorAssetFolder: deleteEditorAssetFolderMock,
    generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
    generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
    generateEditorImage: generateEditorImageMock,
    loadEditorAssetLibrary: loadEditorAssetLibraryMock,
    loadEditorProject: loadEditorProjectMock,
    loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
    renameEditorProject: renameEditorProjectMock,
    saveEditorProjectLayout: saveEditorProjectLayoutMock,
    updateEditorAsset: updateEditorAssetMock,
    updateEditorAssetFolder: updateEditorAssetFolderMock,
  };
});

function dispatchPointerEvent(
  target: Element,
  type: string,
  init: MouseEventInit & { pointerId: number },
) {
  const event = new MouseEvent(type, {
    bubbles: true,
    cancelable: true,
    ...init,
  });
  Object.defineProperty(event, 'pointerId', { value: init.pointerId });
  fireEvent(target, event);
}

function immediateAsync<T>(value: T) {
  return {
    then(onFulfilled: (value: T) => unknown) {
      onFulfilled(value);
      return {
        catch() {},
      };
    },
  };
}

function createDataTransferStub() {
  const store = new Map<string, string>();
  return {
    files: [],
    types: [] as string[],
    dropEffect: 'none',
    effectAllowed: 'all',
    setData(type: string, value: string) {
      store.set(type, value);
      if (!this.types.includes(type)) {
        this.types.push(type);
      }
    },
    getData(type: string) {
      return store.get(type) ?? '';
    },
  };
}

function createDeferred<T>() {
  let resolve!: (value: T) => void;
  let reject!: (reason?: unknown) => void;
  const promise = new Promise<T>((promiseResolve, promiseReject) => {
    resolve = promiseResolve;
    reject = promiseReject;
  });
  return { promise, resolve, reject };
}

async function readZipText(zip: JSZip, path: string) {
  const file = zip.file(path);
  expect(file).toBeTruthy();
  return file!.async('string');
}

describe('ImageCanvasEditorView', () => {
  beforeEach(() => {
    loadOrCreateRecentEditorProjectMock.mockImplementation(() =>
      immediateAsync({
        projectId: 'editor-project-default',
        title: '默认项目',
        viewport: { x: 0, y: 0, scale: 1 },
        layers: defaultEditorProjectLayers,
        resources: defaultEditorProjectResources,
        updatedAt: '2026-06-12T00:00:00.000Z',
      }),
    );
    loadEditorAssetLibraryMock.mockImplementation(() =>
      immediateAsync({
        folders: [
          {
            folderId: 'project',
            label: '项目素材',
            sortOrder: 0,
            collapsed: false,
            systemDefault: true,
          },
        ],
        assets: defaultEditorAssetLibraryAssets,
      }),
    );
    createEditorAssetMock.mockImplementation(async (input) => ({
      assetId: `persisted-${input.label}`,
      folderId: input.folderId,
      label: input.label,
      imageSrc: input.imageSrc,
      width: input.width,
      height: input.height,
      sourceType: input.sourceType,
    }));
    createEditorAssetFolderMock.mockResolvedValue({
      folderId: 'folder-role-persisted',
      label: '角色上传',
      collapsed: false,
      systemDefault: false,
    });
    updateEditorAssetMock.mockImplementation(async (assetId, input) => ({
      assetId,
      folderId: input.folderId ?? 'project',
      label: input.label ?? '拼图素材',
      imageSrc: '/creation-type-references/puzzle.webp',
      width: 640,
      height: 640,
      sourceType: 'uploaded',
    }));
    renameEditorProjectMock.mockImplementation(async (projectId, title) => ({
      projectId,
      title,
      viewport: { x: 0, y: 0, scale: 1 },
      layers: defaultEditorProjectLayers,
      resources: defaultEditorProjectResources,
      updatedAt: '2026-06-12T00:00:00.000Z',
    }));
    updateEditorAssetFolderMock.mockImplementation(async (folderId, input) => ({
      folderId,
      label: input.label ?? '角色上传',
      collapsed: input.collapsed ?? false,
      systemDefault: false,
    }));
    deleteEditorAssetFolderMock.mockResolvedValue({
      folders: [
        {
          folderId: 'project',
          label: '项目素材',
          sortOrder: 0,
          collapsed: false,
          systemDefault: true,
        },
      ],
      assets: [],
    });
    deleteEditorAssetMock.mockResolvedValue({});
    createEditorProjectResourceMock.mockImplementation(
      async (projectId, input) => ({
        resourceId: `resource-${projectId}-${input.width}`,
        projectId,
        imageSrc: input.imageSrc,
        width: input.width,
        height: input.height,
        sourceType: input.sourceType,
      }),
    );
    saveEditorProjectLayoutMock.mockResolvedValue({});
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.restoreAllMocks();
    generateEditorImageMock.mockReset();
    generateEditorIconSpritesheetMock.mockReset();
    generateEditorCharacterAnimationMock.mockReset();
    editEditorImageMock.mockReset();
    createEditorAssetMock.mockReset();
    createEditorProjectResourceMock.mockReset();
    createEditorAssetFolderMock.mockReset();
    updateEditorAssetMock.mockReset();
    updateEditorAssetFolderMock.mockReset();
    deleteEditorAssetFolderMock.mockReset();
    deleteEditorAssetMock.mockReset();
    loadEditorAssetLibraryMock.mockReset();
    loadEditorProjectMock.mockReset();
    loadOrCreateRecentEditorProjectMock.mockReset();
    renameEditorProjectMock.mockReset();
    saveEditorProjectLayoutMock.mockReset();
    window.history.replaceState(null, '', '/editor/canvas');
  });

  it('loads the project from projectid query before falling back to recent project', async () => {
    loadEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-query',
      title: '查询项目',
      viewport: { x: 12, y: 16, scale: 0.8 },
      layers: [],
      resources: [],
      updatedAt: '2026-06-12T00:00:00.000Z',
    });
    window.history.replaceState(
      null,
      '',
      '/editor/canvas?projectid=editor-project-query',
    );

    render(<ImageCanvasEditorView />);

    await waitFor(() => {
      expect(loadEditorProjectMock).toHaveBeenCalledWith(
        'editor-project-query',
      );
    });
    expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
  });

  it('shows the loaded project title and a topbar entry back to projects', async () => {
    render(<ImageCanvasEditorView />);

    expect(
      await screen.findByRole('heading', { name: '默认项目' }),
    ).toBeTruthy();
    const projectLink = screen.getByRole('link', { name: '返回项目页面' });

    expect(projectLink.getAttribute('href')).toBe('/project');
    expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull();
  });

  it('opens login modal when the asset library is unauthorized', async () => {
    const openLoginModal = vi.fn();
    loadEditorAssetLibraryMock.mockRejectedValueOnce(
      new ApiClientError({
        message: '未授权访问',
        status: 401,
        code: 'UNAUTHORIZED',
      }),
    );

    render(
      <AuthUiContext.Provider
        value={createAuthValue({
          user: {
            id: 'user-1',
            publicUserCode: 'U001',
            displayName: '测试用户',
            avatarUrl: null,
            phoneNumberMasked: '138****0000',
            loginMethod: 'password',
            bindingStatus: 'active',
            wechatBound: false,
          },
          canAccessProtectedData: true,
          openLoginModal,
        })}
      >
        <ImageCanvasEditorView />
      </AuthUiContext.Provider>,
    );

    await waitFor(() => {
      expect(openLoginModal).toHaveBeenCalledTimes(1);
    });
  });

  it('opens the login modal immediately when entering the editor while logged out', async () => {
    const openLoginModal = vi.fn();

    render(
      <AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
        <ImageCanvasEditorView />
      </AuthUiContext.Provider>,
    );

    await waitFor(() => {
      expect(openLoginModal).toHaveBeenCalledTimes(1);
    });
    expect(typeof openLoginModal.mock.calls[0]?.[0]).toBe('function');
    expect(loadEditorAssetLibraryMock).not.toHaveBeenCalled();
    expect(loadEditorProjectMock).not.toHaveBeenCalled();
    expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
  });

  it('renames the current project from the canvas topbar', async () => {
    render(<ImageCanvasEditorView />);

    await screen.findByRole('heading', { name: '默认项目' });
    fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' }));
    fireEvent.change(screen.getByLabelText('项目名称'), {
      target: { value: '新画布项目' },
    });
    fireEvent.click(screen.getByRole('button', { name: '保存项目名称' }));

    await waitFor(() => {
      expect(renameEditorProjectMock).toHaveBeenCalledWith(
        'editor-project-default',
        '新画布项目',
      );
    });
    expect(
      await screen.findByRole('heading', { name: '新画布项目' }),
    ).toBeTruthy();
  });

  it('does not inject built-in mock assets when the persisted library is empty', async () => {
    loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-empty',
      title: '空画布',
      viewport: { x: 0, y: 0, scale: 1 },
      layers: [],
      resources: [],
      updatedAt: '2026-06-12T00:00:00.000Z',
    });
    loadEditorAssetLibraryMock.mockResolvedValueOnce({
      folders: [
        {
          folderId: 'project',
          label: '项目素材',
          sortOrder: 0,
          collapsed: false,
          systemDefault: true,
        },
      ],
      assets: [],
    });

    render(<ImageCanvasEditorView />);

    expect(
      await screen.findByRole('region', { name: '项目素材' }),
    ).toBeTruthy();
    expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
    expect(screen.queryByRole('button', { name: '添加大鱼素材' })).toBeNull();
    expect(screen.queryByAltText(/画布图片：拼图素材/u)).toBeNull();
  });

  it('exports valid canvas assets as a zip from the topbar with metadata', async () => {
    loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-export',
      title: '导出项目',
      viewport: { x: 0, y: 0, scale: 1 },
      layers: [
        {
          layerId: 'layer-data-a',
          resourceId: 'resource-data-a',
          title: '素材/A',
          src: 'data:image/png;base64,YQ==',
          x: 12,
          y: 24,
          width: 320,
          height: 220,
          originalWidth: 640,
          originalHeight: 440,
          zIndex: 1,
          sourceType: 'uploaded',
          sourceAssetId: 'asset-data-a',
          groupId: 'group-a',
          hidden: true,
          locked: true,
          flipX: true,
        },
        {
          layerId: 'layer-data-a-copy',
          resourceId: 'resource-data-a-copy',
          title: '素材/A 副本',
          src: 'data:image/png;base64,YQ==',
          x: 42,
          y: 54,
          width: 320,
          height: 220,
          originalWidth: 640,
          originalHeight: 440,
          zIndex: 2,
          sourceType: 'uploaded',
          sourceAssetId: 'asset-data-a',
        },
        {
          layerId: 'layer-generated',
          resourceId: 'resource-generated',
          title: '生成图',
          src: '/generated-ok.png',
          x: 70,
          y: 80,
          width: 360,
          height: 360,
          originalWidth: 1024,
          originalHeight: 1024,
          zIndex: 3,
          sourceType: 'generated',
          prompt: '明亮主视觉',
          model: 'gpt-image-2',
          provider: 'VectorEngine',
          taskId: 'task-1',
        },
        {
          layerId: 'layer-failed',
          resourceId: 'resource-failed',
          title: '失败图',
          src: '/missing.png',
          x: 90,
          y: 100,
          width: 120,
          height: 120,
          originalWidth: 120,
          originalHeight: 120,
          zIndex: 4,
          sourceType: 'generated',
        },
      ],
      resources: [],
      updatedAt: '2026-06-12T00:00:00.000Z',
    });
    loadEditorAssetLibraryMock.mockResolvedValueOnce({
      folders: [
        {
          folderId: 'project',
          label: '项目素材',
          sortOrder: 0,
          collapsed: false,
          systemDefault: true,
        },
      ],
      assets: [
        {
          assetId: 'asset-data-a',
          folderId: 'project',
          label: '素材/A',
          imageSrc: 'data:image/png;base64,YQ==',
          width: 640,
          height: 440,
          sourceType: 'uploaded',
        },
      ],
    });
    const originalFetch = globalThis.fetch;
    const fetchMock = vi.fn(async (url: string) => {
      if (url === '/generated-ok.png') {
        return new Response(new Blob(['generated'], { type: 'image/png' }));
      }
      return new Response(null, { status: 404 });
    });
    globalThis.fetch = fetchMock as typeof fetch;
    const originalCreateObjectUrl = URL.createObjectURL;
    const originalRevokeObjectUrl = URL.revokeObjectURL;
    const originalAnchorClick = HTMLAnchorElement.prototype.click;
    let exportedBlob: Blob | null = null;
    let downloadName = '';
    URL.createObjectURL = vi.fn((blob: Blob) => {
      exportedBlob = blob;
      return 'blob:editor-export';
    });
    URL.revokeObjectURL = vi.fn();
    HTMLAnchorElement.prototype.click = vi.fn(function click(
      this: HTMLAnchorElement,
    ) {
      downloadName = this.download;
    });

    try {
      render(<ImageCanvasEditorView />);

      await screen.findByRole('heading', { name: '导出项目' });
      await waitFor(() => {
        expect(
          (
            screen.getByRole('button', {
              name: '下载画布素材',
            }) as HTMLButtonElement
          ).disabled,
        ).toBe(false);
      });
      fireEvent.click(screen.getByRole('button', { name: '下载画布素材' }));

      await waitFor(() => {
        expect(exportedBlob).toBeTruthy();
      });
      expect(downloadName).toMatch(/^导出项目-画布素材-\d{8}\.zip$/u);

      const zip = await JSZip.loadAsync(exportedBlob!);
      expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy();
      expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy();
      expect(zip.file('导出项目-画布素材/images/003-失败图.png')).toBeNull();

      const metadata = JSON.parse(
        await readZipText(zip, '导出项目-画布素材/metadata.json'),
      );
      expect(metadata.projectId).toBe('editor-project-export');
      expect(metadata.layers).toHaveLength(4);
      expect(metadata.layers[0].file).toBe('images/001-素材 A.png');
      expect(metadata.layers[1].file).toBe('images/001-素材 A.png');
      expect(metadata.layers[0].canvas.hidden).toBe(true);
      expect(metadata.layers[0].canvas.locked).toBe(true);
      expect(metadata.layers[0].canvas.flipX).toBe(true);
      expect(metadata.layers[0].canvas.groupId).toBe('group-a');
      expect(metadata.layers[2].sourceType).toBe('generated');
      expect(metadata.layers[2].prompt).toBe('明亮主视觉');
      expect(metadata.layers[3].file).toBeNull();
      expect(metadata.layers[3].exportError).toContain('404');
      expect(metadata.failedImages).toHaveLength(1);
      expect(
        await readZipText(zip, '导出项目-画布素材/manifest.txt'),
      ).toContain('失败素材数量：1');
      expect(screen.getByText('部分素材未能导出')).toBeTruthy();
    } finally {
      globalThis.fetch = originalFetch;
      URL.createObjectURL = originalCreateObjectUrl;
      URL.revokeObjectURL = originalRevokeObjectUrl;
      HTMLAnchorElement.prototype.click = originalAnchorClick;
    }
  });

  it('disables the canvas asset export entry when there are no valid layers', async () => {
    loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-empty-export',
      title: '空导出项目',
      viewport: { x: 0, y: 0, scale: 1 },
      layers: [],
      resources: [],
      updatedAt: '2026-06-12T00:00:00.000Z',
    });
    loadEditorAssetLibraryMock.mockResolvedValueOnce({
      folders: [
        {
          folderId: 'project',
          label: '项目素材',
          sortOrder: 0,
          collapsed: false,
          systemDefault: true,
        },
      ],
      assets: [],
    });

    render(<ImageCanvasEditorView />);

    await screen.findByRole('heading', { name: '空导出项目' });
    expect(
      (
        screen.getByRole('button', {
          name: '下载画布素材',
        }) as HTMLButtonElement
      ).disabled,
    ).toBe(true);
  });

  it('keeps only one default asset folder when the persisted library returns duplicated defaults', async () => {
    loadEditorAssetLibraryMock.mockResolvedValueOnce({
      folders: [
        {
          folderId: 'project',
          label: '项目素材',
          sortOrder: 0,
          collapsed: false,
          systemDefault: true,
        },
        {
          folderId: 'legacy-project',
          label: '旧项目素材',
          sortOrder: 1,
          collapsed: false,
          systemDefault: true,
        },
      ],
      assets: [],
    });

    render(<ImageCanvasEditorView />);

    expect(
      await screen.findByRole('region', { name: '项目素材' }),
    ).toBeTruthy();
    expect(screen.queryByRole('region', { name: '旧项目素材' })).toBeNull();
    expect(screen.getAllByRole('button', { name: /上传到/u })).toHaveLength(1);
  });

  it('toggles the shared sidebar from canvas panel buttons', () => {
    render(<ImageCanvasEditorView />);

    const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
    const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
    const assetsButton = within(panelToolbar).getByRole('button', {
      name: '打开素材',
    });
    const layersButton = within(panelToolbar).getByRole('button', {
      name: '打开图层',
    });

    expect(within(sidebar).getByText('素材')).toBeTruthy();
    expect(
      within(sidebar).getByRole('button', { name: '添加拼图素材' }),
    ).toBeTruthy();
    expect(assetsButton.getAttribute('aria-pressed')).toBe('true');
    expect(screen.queryByRole('button', { name: '打开已生成文件' })).toBeNull();
    expect(screen.queryByRole('button', { name: '收起素材栏' })).toBeNull();
    expect(screen.queryByRole('button', { name: '展开素材栏' })).toBeNull();

    fireEvent.click(layersButton);

    const layerSidebar = screen.getByRole('complementary', {
      name: '图片资源栏',
    });
    expect(within(layerSidebar).getByText('图层')).toBeTruthy();
    expect(
      within(layerSidebar).getByRole('button', { name: '选择图层拼图素材' }),
    ).toBeTruthy();
    expect(layersButton.getAttribute('aria-pressed')).toBe('true');
    expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();

    fireEvent.click(layersButton);

    expect(
      screen.queryByRole('complementary', { name: '图片资源栏' }),
    ).toBeNull();
    expect(layersButton.getAttribute('aria-pressed')).toBe('false');
  });

  it('groups assets by folder and renames sidebar materials', async () => {
    const user = userEvent.setup();
    render(<ImageCanvasEditorView />);

    const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
    expect(
      within(sidebar).getByRole('region', { name: '项目素材' }),
    ).toBeTruthy();
    expect(
      within(sidebar).queryByRole('region', { name: '参考素材' }),
    ).toBeNull();

    await user.click(
      screen.getByRole('button', { name: '重命名素材拼图素材' }),
    );
    const renameInput = screen.getByLabelText('重命名素材拼图素材');
    await user.clear(renameInput);
    await user.type(renameInput, '主视觉素材');
    await user.click(
      screen.getByRole('button', { name: '保存素材拼图素材名称' }),
    );

    expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
    await user.click(screen.getByRole('button', { name: '添加主视觉素材' }));

    expect(screen.getByAltText('画布图片：主视觉素材')).toBeTruthy();
  });

  it('collapses folders, creates upload folders, and deletes uploaded materials', async () => {
    const user = userEvent.setup();
    const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image');
    Object.defineProperty(URL, 'createObjectURL', {
      configurable: true,
      value: createObjectUrlSpy,
    });
    render(<ImageCanvasEditorView />);

    await user.click(screen.getByRole('button', { name: '折叠项目素材' }));
    expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
    await user.click(screen.getByRole('button', { name: '展开项目素材' }));
    expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy();

    await user.click(screen.getByRole('button', { name: '新建素材文件夹' }));
    const folderNameInput = screen.getByLabelText('素材文件夹名称');
    await user.type(folderNameInput, '角色上传');
    await user.click(screen.getByRole('button', { name: '保存素材文件夹' }));

    const uploadInput = screen.getByLabelText('上传图片文件');
    await user.click(screen.getByRole('button', { name: '上传到角色上传' }));
    await userEvent.upload(
      uploadInput,
      new File(['image'], '角色草图.png', { type: 'image/png' }),
    );

    const customFolder = screen.getByRole('region', { name: '角色上传' });
    await waitFor(() => {
      expect(
        within(customFolder).getByRole('button', { name: '添加角色草图.png' }),
      ).toBeTruthy();
      expect(
        within(customFolder).getByRole('button', {
          name: '删除素材角色草图.png',
        }),
      ).toBeTruthy();
    });

    await user.click(
      within(customFolder).getByRole('button', {
        name: '删除素材角色草图.png',
      }),
    );

    expect(
      screen.queryByRole('button', { name: '添加角色草图.png' }),
    ).toBeNull();
    expect(screen.queryByAltText('画布图片：角色草图.png')).toBeNull();
  });

  it('renames and deletes asset folders through the persisted asset library API', async () => {
    const user = userEvent.setup();
    loadEditorAssetLibraryMock.mockResolvedValueOnce({
      folders: [
        {
          folderId: 'project',
          label: '项目素材',
          sortOrder: 0,
          collapsed: false,
          systemDefault: true,
        },
        {
          folderId: 'folder-role',
          label: '角色',
          sortOrder: 100,
          collapsed: false,
          systemDefault: false,
        },
      ],
      assets: [],
    });
    render(<ImageCanvasEditorView />);

    await screen.findByRole('region', { name: '角色' });
    await user.click(screen.getByRole('button', { name: '重命名文件夹角色' }));
    const folderRenameInput = screen.getByLabelText('重命名文件夹角色');
    await user.clear(folderRenameInput);
    await user.type(folderRenameInput, '角色参考');
    await user.click(
      screen.getByRole('button', { name: '保存文件夹角色名称' }),
    );

    expect(updateEditorAssetFolderMock).toHaveBeenCalledWith('folder-role', {
      label: '角色参考',
    });

    await user.click(
      screen.getByRole('button', { name: '删除文件夹角色参考' }),
    );

    expect(deleteEditorAssetFolderMock).toHaveBeenCalledWith('folder-role');
  });

  it('moves an asset to another folder when dragging inside the asset library', async () => {
    loadEditorAssetLibraryMock.mockResolvedValueOnce({
      folders: [
        {
          folderId: 'project',
          label: '项目素材',
          sortOrder: 0,
          collapsed: false,
          systemDefault: true,
        },
        {
          folderId: 'folder-role',
          label: '角色',
          sortOrder: 100,
          collapsed: false,
          systemDefault: false,
        },
      ],
      assets: [
        {
          assetId: 'asset-puzzle',
          folderId: 'project',
          label: '拼图素材',
          imageSrc: '/creation-type-references/puzzle.webp',
          width: 640,
          height: 640,
          sourceType: 'uploaded',
        },
      ],
    });
    render(<ImageCanvasEditorView />);

    const sourceAsset = await screen.findByRole('button', {
      name: '添加拼图素材',
    });
    const sourceAssetRow = sourceAsset.closest(
      '.image-canvas-editor__asset-row',
    );
    const projectFolder = screen.getByRole('region', { name: '项目素材' });
    const roleFolder = screen.getByRole('region', { name: '角色' });
    const dataTransfer = createDataTransferStub();

    if (!sourceAssetRow) {
      throw new Error('asset row should exist');
    }
    fireEvent.dragStart(sourceAssetRow, { dataTransfer });
    fireEvent.dragOver(roleFolder, { dataTransfer });
    await waitFor(() => {
      expect(screen.queryByText('添加到素材')).toBeNull();
      expect(roleFolder.className).toContain(
        'image-canvas-editor__asset-folder--move-target',
      );
    });
    fireEvent.drop(roleFolder, { dataTransfer });

    expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-puzzle', {
      folderId: 'folder-role',
    });
    expect(
      within(projectFolder).queryByRole('button', { name: '添加拼图素材' }),
    ).toBeNull();
    expect(
      within(roleFolder).getByRole('button', { name: '添加拼图素材' }),
    ).toBeTruthy();
    expect(createEditorAssetMock).not.toHaveBeenCalled();
  });

  it('uploads multiple files as account-level assets without adding canvas layers', async () => {
    render(<ImageCanvasEditorView />);

    await userEvent.upload(screen.getByLabelText('上传图片文件'), [
      new File(['image-a'], '第一张.png', { type: 'image/png' }),
      new File(['image-b'], '第二张.png', { type: 'image/png' }),
    ]);

    await waitFor(() => {
      expect(
        screen.getByRole('button', { name: '添加第一张.png' }),
      ).toBeTruthy();
      expect(
        screen.getByRole('button', { name: '添加第二张.png' }),
      ).toBeTruthy();
    });
    expect(createEditorAssetMock).toHaveBeenCalledTimes(2);
    expect(screen.queryByAltText('画布图片：第一张.png')).toBeNull();
    expect(screen.queryByAltText('画布图片：第二张.png')).toBeNull();
  });

  it('opens login before uploading assets while logged out and resumes after login', async () => {
    const openLoginModal = vi.fn();
    const authValue = createAuthValue({ openLoginModal });

    const { rerender } = render(
      <AuthUiContext.Provider value={authValue}>
        <ImageCanvasEditorView />
      </AuthUiContext.Provider>,
    );

    await userEvent.upload(screen.getByLabelText('上传图片文件'), [
      new File(['image'], '登录后上传.png', { type: 'image/png' }),
    ]);

    expect(openLoginModal).toHaveBeenCalled();
    expect(createEditorAssetMock).not.toHaveBeenCalled();
    expect(
      screen.queryByRole('button', { name: '上传失败登录后上传.png' }),
    ).toBeNull();

    const resumeUpload =
      openLoginModal.mock.calls[openLoginModal.mock.calls.length - 1]?.[0];
    expect(typeof resumeUpload).toBe('function');
    rerender(
      <AuthUiContext.Provider
        value={createAuthValue({
          user: {
            id: 'user-1',
            publicUserCode: 'U001',
            displayName: '测试用户',
            avatarUrl: null,
            phoneNumberMasked: '138****0000',
            loginMethod: 'password',
            bindingStatus: 'active',
            wechatBound: false,
          },
          canAccessProtectedData: true,
          openLoginModal,
        })}
      >
        <ImageCanvasEditorView />
      </AuthUiContext.Provider>,
    );
    act(() => {
      (resumeUpload as () => void)();
    });

    await waitFor(() => {
      expect(createEditorAssetMock).toHaveBeenCalledTimes(1);
    });
  });

  it('shows an uploading placeholder card before restoring the normal asset card', async () => {
    const deferredAsset = createDeferred<{
      assetId: string;
      folderId: string;
      label: string;
      imageSrc: string;
      width: number;
      height: number;
      sourceType: 'uploaded';
    }>();
    createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise);
    render(<ImageCanvasEditorView />);

    await userEvent.upload(screen.getByLabelText('上传图片文件'), [
      new File(['image'], '素材上传进度.png', { type: 'image/png' }),
    ]);

    expect(
      await screen.findByLabelText('素材素材上传进度.png上传进度'),
    ).toBeTruthy();
    expect(
      screen.getByRole('button', { name: '上传中素材上传进度.png' }),
    ).toBeTruthy();

    deferredAsset.resolve({
      assetId: 'asset-upload-progress',
      folderId: 'project',
      label: '素材上传进度.png',
      imageSrc: 'data:image/png;base64,cHJvZ3Jlc3M=',
      width: 420,
      height: 315,
      sourceType: 'uploaded',
    });

    await waitFor(() => {
      expect(
        screen.getByRole('button', { name: '添加素材上传进度.png' }),
      ).toBeTruthy();
    });
    expect(screen.queryByLabelText('素材素材上传进度.png上传进度')).toBeNull();
  });

  it('opens login when asset creation returns unauthorized during upload', async () => {
    const openLoginModal = vi.fn();
    createEditorAssetMock.mockRejectedValueOnce(
      new ApiClientError({
        message: '未授权访问',
        status: 401,
        code: 'UNAUTHORIZED',
      }),
    );

    render(
      <AuthUiContext.Provider
        value={createAuthValue({
          user: {
            id: 'user-1',
            publicUserCode: 'U001',
            displayName: '测试用户',
            avatarUrl: null,
            phoneNumberMasked: '138****0000',
            loginMethod: 'password',
            bindingStatus: 'active',
            wechatBound: false,
          },
          canAccessProtectedData: true,
          openLoginModal,
        })}
      >
        <ImageCanvasEditorView />
      </AuthUiContext.Provider>,
    );

    await userEvent.upload(screen.getByLabelText('上传图片文件'), [
      new File(['image'], '过期登录.png', { type: 'image/png' }),
    ]);

    await waitFor(() => {
      expect(openLoginModal).toHaveBeenCalledTimes(1);
    });
    expect(screen.getByText('请先登录')).toBeTruthy();
  });

  it('supports asset selection mode and batch delete with shared toolbar', async () => {
    const user = userEvent.setup();
    loadEditorAssetLibraryMock.mockResolvedValueOnce({
      folders: [
        {
          folderId: 'project',
          label: '项目素材',
          sortOrder: 0,
          collapsed: false,
          systemDefault: true,
        },
      ],
      assets: [
        {
          assetId: 'asset-a',
          folderId: 'project',
          label: '账号素材A',
          imageSrc: 'data:image/png;base64,YQ==',
          width: 320,
          height: 240,
          sourceType: 'uploaded',
        },
        {
          assetId: 'asset-b',
          folderId: 'project',
          label: '账号素材B',
          imageSrc: 'data:image/png;base64,Yg==',
          width: 320,
          height: 240,
          sourceType: 'uploaded',
        },
      ],
    });
    render(<ImageCanvasEditorView />);

    await screen.findByRole('button', { name: '添加账号素材A' });
    await user.click(screen.getByRole('button', { name: '素材选择模式' }));
    await user.click(screen.getByRole('button', { name: '选择素材账号素材A' }));

    const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
    expect(within(batchToolbar).getByText(/已选 1/u)).toBeTruthy();
    await user.click(
      within(batchToolbar).getByRole('button', { name: '删除' }),
    );

    expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
    expect(
      screen.queryByRole('button', { name: '选择素材账号素材A' }),
    ).toBeNull();
  });

  it('removes canvas layers linked to deleted assets', async () => {
    const user = userEvent.setup();
    loadEditorAssetLibraryMock.mockResolvedValueOnce({
      folders: [
        {
          folderId: 'project',
          label: '项目素材',
          sortOrder: 0,
          collapsed: false,
          systemDefault: true,
        },
      ],
      assets: [
        {
          assetId: 'asset-a',
          folderId: 'project',
          label: '账号素材A',
          imageSrc: 'data:image/png;base64,YQ==',
          width: 320,
          height: 240,
          sourceType: 'uploaded',
        },
        {
          assetId: 'asset-b',
          folderId: 'project',
          label: '账号素材B',
          imageSrc: 'data:image/png;base64,Yg==',
          width: 320,
          height: 240,
          sourceType: 'uploaded',
        },
      ],
    });
    render(<ImageCanvasEditorView />);

    await user.click(
      await screen.findByRole('button', { name: '添加账号素材A' }),
    );
    await user.click(screen.getByRole('button', { name: '添加账号素材B' }));
    expect(screen.getByAltText('画布图片：账号素材A')).toBeTruthy();
    expect(screen.getByAltText('画布图片：账号素材B')).toBeTruthy();

    await user.click(screen.getByRole('button', { name: '素材选择模式' }));
    await user.click(
      within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole(
        'button',
        { name: '全选' },
      ),
    );
    await waitFor(() => {
      expect(
        within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText(
          /已选 2/u,
        ),
      ).toBeTruthy();
    });
    await user.click(
      within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole(
        'button',
        { name: '删除' },
      ),
    );

    await waitFor(() => {
      expect(screen.queryByAltText('画布图片：账号素材A')).toBeNull();
      expect(screen.queryByAltText('画布图片：账号素材B')).toBeNull();
    });
    expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
    expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b');
  });

  it('saves a library asset layer right after creating its canvas resource', async () => {
    const user = userEvent.setup();
    createEditorProjectResourceMock.mockResolvedValueOnce({
      resourceId: 'resource-added-asset-a',
      projectId: 'editor-project-default',
      imageSrc: 'data:image/png;base64,YQ==',
      width: 320,
      height: 240,
      sourceType: 'uploaded',
    });
    loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-default',
      title: '空画布项目',
      viewport: { x: 0, y: 0, scale: 1 },
      layers: [],
      resources: [],
      updatedAt: '2026-06-12T00:00:00.000Z',
    });
    loadEditorAssetLibraryMock.mockResolvedValueOnce({
      folders: [
        {
          folderId: 'project',
          label: '项目素材',
          sortOrder: 0,
          collapsed: false,
          systemDefault: true,
        },
      ],
      assets: [
        {
          assetId: 'asset-a',
          folderId: 'project',
          label: '账号素材A',
          imageSrc: 'data:image/png;base64,YQ==',
          width: 320,
          height: 240,
          sourceType: 'uploaded',
        },
      ],
    });
    render(<ImageCanvasEditorView />);

    await user.click(
      await screen.findByRole('button', { name: '添加账号素材A' }),
    );

    expect(await screen.findByAltText('画布图片：账号素材A')).toBeTruthy();
    await waitFor(() => {
      expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
        'editor-project-default',
        expect.objectContaining({
          layers: expect.arrayContaining([
            expect.objectContaining({
              title: '账号素材A',
              resourceId: 'resource-added-asset-a',
              sourceAssetId: 'asset-a',
            }),
          ]),
        }),
      );
    });
  });

  it('selects multiple assets with a marquee in asset selection mode', async () => {
    const user = userEvent.setup();
    loadEditorAssetLibraryMock.mockResolvedValueOnce({
      folders: [
        {
          folderId: 'project',
          label: '项目素材',
          sortOrder: 0,
          collapsed: false,
          systemDefault: true,
        },
      ],
      assets: [
        {
          assetId: 'asset-a',
          folderId: 'project',
          label: '账号素材A',
          imageSrc: 'data:image/png;base64,YQ==',
          width: 320,
          height: 240,
          sourceType: 'uploaded',
        },
        {
          assetId: 'asset-b',
          folderId: 'project',
          label: '账号素材B',
          imageSrc: 'data:image/png;base64,Yg==',
          width: 320,
          height: 240,
          sourceType: 'uploaded',
        },
      ],
    });
    render(<ImageCanvasEditorView />);

    const firstAssetButton = await screen.findByRole('button', {
      name: '添加账号素材A',
    });
    const secondAssetButton = screen.getByRole('button', {
      name: '添加账号素材B',
    });
    const assetList = firstAssetButton.closest(
      '.image-canvas-editor__asset-list',
    ) as HTMLElement;
    vi.spyOn(assetList, 'getBoundingClientRect').mockReturnValue({
      x: 0,
      y: 0,
      left: 0,
      top: 0,
      right: 320,
      bottom: 600,
      width: 320,
      height: 600,
      toJSON: () => ({}),
    });
    vi.spyOn(
      firstAssetButton.closest('[data-asset-id]') as HTMLElement,
      'getBoundingClientRect',
    ).mockReturnValue({
      x: 16,
      y: 120,
      left: 16,
      top: 120,
      right: 280,
      bottom: 200,
      width: 264,
      height: 80,
      toJSON: () => ({}),
    });
    vi.spyOn(
      secondAssetButton.closest('[data-asset-id]') as HTMLElement,
      'getBoundingClientRect',
    ).mockReturnValue({
      x: 16,
      y: 240,
      left: 16,
      top: 240,
      right: 280,
      bottom: 320,
      width: 264,
      height: 80,
      toJSON: () => ({}),
    });

    await user.click(screen.getByRole('button', { name: '素材选择模式' }));
    dispatchPointerEvent(assetList, 'pointerdown', {
      button: 0,
      pointerId: 88,
      clientX: 8,
      clientY: 100,
    });
    dispatchPointerEvent(assetList, 'pointermove', {
      button: 0,
      pointerId: 88,
      clientX: 300,
      clientY: 330,
    });
    dispatchPointerEvent(assetList, 'pointerup', {
      button: 0,
      pointerId: 88,
      clientX: 300,
      clientY: 330,
    });

    const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
    expect(within(batchToolbar).getByText(/已选 2/u)).toBeTruthy();
  });

  it('shows image resolution on hover and placeholder toolbar after selecting a layer', () => {
    const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
    render(<ImageCanvasEditorView />);

    const canvasImage = screen.getByAltText('画布图片：拼图素材');
    fireEvent.mouseEnter(canvasImage.closest('button')!);

    const sizeBadge = screen.getByText('640 x 640 px');
    expect(sizeBadge.className).toContain('rounded-full');
    expect(sizeBadge.className).toContain('image-canvas-editor__size-badge');

    fireEvent.pointerDown(canvasImage.closest('button')!, {
      button: 0,
      pointerId: 1,
      clientX: 120,
      clientY: 120,
    });

    const cropButton = screen.getByRole('button', { name: '裁剪占位' });
    fireEvent.pointerDown(cropButton, {
      button: 0,
      pointerId: 2,
      clientX: 120,
      clientY: 96,
    });
    fireEvent.click(cropButton);

    expect(alertSpy).toHaveBeenCalledWith('裁剪功能建设中');
    expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
  });

  it('opens image info for uploaded canvas images without generated edit tools', () => {
    render(<ImageCanvasEditorView />);

    fireEvent.pointerDown(
      screen.getByAltText('画布图片：拼图素材').closest('button')!,
      {
        button: 0,
        pointerId: 61,
        clientX: 120,
        clientY: 120,
      },
    );

    const infoButton = screen.getByRole('button', {
      name: '查看拼图素材图片信息',
    });
    expect(infoButton.className).toContain(
      'image-canvas-editor__metadata-corner',
    );
    fireEvent.click(infoButton);

    const infoPanel = screen.getByRole('dialog', { name: '拼图素材图片信息' });
    expect(within(infoPanel).getByText('图片类型')).toBeTruthy();
    expect(within(infoPanel).getByText('上传图片')).toBeTruthy();
    expect(within(infoPanel).getByText('生成输入')).toBeTruthy();
    expect(
      infoPanel.querySelector('.image-canvas-editor__metadata-inputs')
        ?.textContent,
    ).toBe('-');
    expect(within(infoPanel).queryByText('Prompt')).toBeNull();
    expect(within(infoPanel).getByText('Model')).toBeTruthy();
    expect(within(infoPanel).queryByText('Size')).toBeNull();
    expect(within(infoPanel).getByText('Resolution')).toBeTruthy();
    expect(within(infoPanel).getByText('640 x 640 px')).toBeTruthy();
    expect(
      within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
    ).toBeNull();
    expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull();
  });

  it('hydrates canvas images from Resolution instead of saved Size', async () => {
    loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-resolution',
      title: '原分辨率画布',
      viewport: { x: 0, y: 0, scale: 1 },
      layers: [
        {
          layerId: 'layer-resolution',
          resourceId: 'resource-resolution',
          title: '旧布局图片',
          src: 'data:image/png;base64,cmVzb2x1dGlvbg==',
          x: 120,
          y: 140,
          width: 320,
          height: 240,
          originalWidth: 1536,
          originalHeight: 1024,
          zIndex: 2,
          sourceType: 'generated',
          model: 'gpt-image-2',
          provider: 'VectorEngine',
          taskId: 'resolution-task-1',
        },
      ],
      resources: [],
      updatedAt: '2026-06-16T00:00:00.000Z',
    });
    render(<ImageCanvasEditorView />);

    const canvasImage = await screen.findByAltText('画布图片：旧布局图片');
    const canvasLayer = canvasImage.closest('button') as HTMLElement;
    expect(Number.parseFloat(canvasLayer.style.width)).toBe(1536);
    expect(Number.parseFloat(canvasLayer.style.height)).toBe(1024);

    fireEvent.mouseEnter(canvasLayer);
    expect(screen.getByText('1536 x 1024 px')).toBeTruthy();
    fireEvent.click(
      screen.getAllByRole('button', { name: '查看旧布局图片图片信息' })[0]!,
    );
    const infoPanel = screen.getByRole('dialog', {
      name: '旧布局图片图片信息',
    });
    expect(within(infoPanel).queryByText('Size')).toBeNull();
    expect(within(infoPanel).getByText('Resolution')).toBeTruthy();
    expect(within(infoPanel).getByText('1536 x 1024 px')).toBeTruthy();
  });

  it('deletes the selected layer from the floating toolbar', () => {
    render(<ImageCanvasEditorView />);

    expect(screen.getByAltText('画布图片：拼图素材')).toBeTruthy();

    fireEvent.pointerDown(
      screen.getByAltText('画布图片：拼图素材').closest('button')!,
      {
        button: 0,
        pointerId: 51,
        clientX: 120,
        clientY: 120,
      },
    );
    fireEvent.click(screen.getByRole('button', { name: '删除图片' }));

    expect(screen.queryByAltText('画布图片：拼图素材')).toBeNull();
    expect(screen.getByAltText('画布图片：大鱼素材')).toBeTruthy();
  });

  it('deletes the selected layer with Backspace when focus is outside text inputs', async () => {
    render(<ImageCanvasEditorView />);

    expect(screen.getByAltText('画布图片：拼图素材')).toBeTruthy();

    fireEvent.pointerDown(
      screen.getByAltText('画布图片：拼图素材').closest('button')!,
      {
        button: 0,
        pointerId: 52,
        clientX: 120,
        clientY: 120,
      },
    );
    await act(async () => {
      fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
    });

    expect(screen.queryByAltText('画布图片：拼图素材')).toBeNull();
    expect(screen.getByAltText('画布图片：大鱼素材')).toBeTruthy();
  });

  it('drops an image file on the canvas as a new canvas layer', async () => {
    render(<ImageCanvasEditorView />);
    await waitFor(() => {
      expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
    });

    const viewport = screen.getByLabelText('画布工作区');
    fireEvent.drop(viewport, {
      clientX: 430,
      clientY: 260,
      dataTransfer: {
        files: [new File(['image'], '测试上传.png', { type: 'image/png' })],
        types: ['Files'],
      },
    });

    await waitFor(() => {
      expect(screen.getByAltText('画布图片：测试上传.png')).toBeTruthy();
    });
    expect(createEditorAssetMock).toHaveBeenCalledWith(
      expect.objectContaining({
        label: '测试上传.png',
        imageSrc: expect.stringMatching(/^data:image\/png;base64,/u),
      }),
    );
    expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy();
    expect(
      screen.getByRole('button', { name: '打开素材' }).getAttribute(
        'aria-pressed',
      ),
    ).toBe('true');
    expect(
      screen
        .getByRole('button', { name: '选择测试上传.png' })
        .className.includes('image-canvas-editor__layer--selected'),
    ).toBe(true);
  });

  it('drops files into the asset panel only once without creating canvas layers', async () => {
    render(<ImageCanvasEditorView />);

    fireEvent.drop(screen.getByRole('region', { name: '项目素材' }), {
      dataTransfer: {
        files: [new File(['image'], '素材拖拽.png', { type: 'image/png' })],
        types: ['Files'],
      },
    });

    await waitFor(() => {
      expect(
        screen.getByRole('button', { name: '添加素材拖拽.png' }),
      ).toBeTruthy();
    });
    expect(createEditorAssetMock).toHaveBeenCalledTimes(1);
    expect(screen.queryByAltText('画布图片：素材拖拽.png')).toBeNull();
  });

  it('adds an asset library image to the canvas by dragging it onto the viewport', async () => {
    render(<ImageCanvasEditorView />);

    const sourceAsset = await screen.findByRole('button', {
      name: '添加抓大鹅素材',
    });
    const sourceAssetRow = sourceAsset.closest(
      '.image-canvas-editor__asset-row',
    );
    const viewport = screen.getByLabelText('画布工作区');
    const dataTransfer = createDataTransferStub();

    if (!sourceAssetRow) {
      throw new Error('asset row should exist');
    }
    fireEvent.dragStart(sourceAssetRow, { dataTransfer });
    fireEvent.dragOver(viewport, {
      clientX: 520,
      clientY: 300,
      dataTransfer,
    });

    await waitFor(() => {
      expect(screen.getByText('添加到画布')).toBeTruthy();
    });

    fireEvent.drop(viewport, {
      clientX: 520,
      clientY: 300,
      dataTransfer,
    });

    await waitFor(() => {
      expect(screen.queryByText('添加到画布')).toBeNull();
    });
    expect(screen.getByAltText('画布图片：抓大鹅素材')).toBeTruthy();
    expect(screen.getByRole('button', { name: '选择抓大鹅素材' })).toBeTruthy();
    expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
      'editor-project-default',
      expect.objectContaining({
        imageSrc: '/creation-type-references/match3d.webp',
        sourceType: 'uploaded',
      }),
    );
    expect(createEditorAssetMock).not.toHaveBeenCalled();
  });

  it('blocks the browser context menu inside the editor workspace', () => {
    render(<ImageCanvasEditorView />);

    const editor = screen.getByRole('region', { name: '图片画布编辑器' });
    const contextMenuEvent = new MouseEvent('contextmenu', {
      bubbles: true,
      cancelable: true,
    });
    const wasNotCanceled = editor.dispatchEvent(contextMenuEvent);

    expect(wasNotCanceled).toBe(false);
    expect(contextMenuEvent.defaultPrevented).toBe(true);
  });

  it('shows the blank canvas context menu with paste disabled, zoom, and fit all', () => {
    render(<ImageCanvasEditorView />);

    const viewport = screen.getByLabelText('画布工作区');
    fireEvent.contextMenu(viewport, {
      clientX: 320,
      clientY: 220,
    });

    const menu = screen.getByRole('menu', { name: '画布右键菜单' });
    expect(
      (
        within(menu).getByRole('menuitem', {
          name: '粘贴',
        }) as HTMLButtonElement
      ).disabled,
    ).toBe(true);
    expect(within(menu).getByRole('menuitem', { name: '放大' })).toBeTruthy();
    expect(
      within(menu).getByRole('menuitem', { name: '显示画布所有元素' }),
    ).toBeTruthy();
  });

  it('keeps right-clicking a canvas layer from falling through to blank pan menu handling', () => {
    render(<ImageCanvasEditorView />);

    const layerButton = screen
      .getByAltText('画布图片：拼图素材')
      .closest('button')!;
    const rightPointerDown = new MouseEvent('pointerdown', {
      bubbles: true,
      cancelable: true,
      button: 2,
      clientX: 510,
      clientY: 330,
    });

    const wasNotCanceled = layerButton.dispatchEvent(rightPointerDown);

    expect(wasNotCanceled).toBe(true);
    expect(rightPointerDown.defaultPrevented).toBe(false);

    fireEvent.contextMenu(layerButton, {
      clientX: 510,
      clientY: 330,
    });

    expect(screen.getByRole('menu', { name: '图片功能面板' })).toBeTruthy();
    expect(screen.getByRole('menuitem', { name: '创建副本' })).toBeTruthy();
  });

  it('copies, cuts, and pastes layers from the context menus', () => {
    render(<ImageCanvasEditorView />);

    fireEvent.contextMenu(
      screen.getByAltText('画布图片：拼图素材').closest('button')!,
      {
        clientX: 510,
        clientY: 330,
      },
    );
    fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));

    fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
      clientX: 360,
      clientY: 240,
    });
    const copyPasteMenu = screen.getByRole('menu', { name: '画布右键菜单' });
    expect(
      (
        within(copyPasteMenu).getByRole('menuitem', {
          name: '粘贴',
        }) as HTMLButtonElement
      ).disabled,
    ).toBe(false);
    fireEvent.click(
      within(copyPasteMenu).getByRole('menuitem', { name: '粘贴' }),
    );
    expect(screen.getAllByAltText(/画布图片：拼图素材/u)).toHaveLength(2);

    fireEvent.contextMenu(
      screen.getByAltText('画布图片：大鱼素材').closest('button')!,
      {
        clientX: 950,
        clientY: 380,
      },
    );
    fireEvent.click(screen.getByRole('menuitem', { name: '剪切' }));
    expect(screen.queryByAltText('画布图片：大鱼素材')).toBeNull();

    fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
      clientX: 420,
      clientY: 260,
    });
    fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
    expect(screen.getByAltText('画布图片：大鱼素材')).toBeTruthy();
  });

  it('handles layer context menu duplicate, ordering, hide, lock, flip, group, ungroup, and delete', async () => {
    render(<ImageCanvasEditorView />);

    const firstLayer = screen
      .getByAltText('画布图片：拼图素材')
      .closest('button')!;
    fireEvent.contextMenu(firstLayer, { clientX: 510, clientY: 330 });
    fireEvent.click(screen.getByRole('menuitem', { name: '创建副本' }));
    expect(screen.getAllByAltText(/画布图片：拼图素材/u)).toHaveLength(2);

    const copiedLayer = screen
      .getAllByAltText(/画布图片：拼图素材/u)[1]!
      .closest('button')!;
    fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 });
    fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' }));
    expect(
      (screen.getAllByAltText(/画布图片：拼图素材/u)[1] as HTMLElement).style
        .transform,
    ).toBe('scale(-1, 1)');

    fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 });
    fireEvent.click(screen.getByRole('menuitem', { name: '锁定' }));
    await waitFor(() => {
      expect(copiedLayer.className).toContain(
        'image-canvas-editor__layer--locked',
      );
    });

    fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 });
    fireEvent.click(screen.getByRole('menuitem', { name: '隐藏' }));
    expect(screen.getAllByAltText(/画布图片：拼图素材/u)).toHaveLength(1);

    fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
    expect(screen.getByText(/已隐藏/u)).toBeTruthy();
    fireEvent.contextMenu(
      screen.getByText(/已隐藏/u).closest('.image-canvas-editor__layer-row')!,
      {
        clientX: 80,
        clientY: 220,
      },
    );
    fireEvent.click(screen.getByRole('menuitem', { name: '显示' }));
    expect(screen.getAllByAltText(/画布图片：拼图素材/u)).toHaveLength(2);

    const bigFishLayer = screen
      .getByAltText('画布图片：大鱼素材')
      .closest('button')!;
    fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
    fireEvent.click(screen.getByRole('menuitem', { name: '置于顶层' }));
    expect(Number.parseInt(bigFishLayer.style.zIndex, 10)).toBeGreaterThan(2);

    fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
    fireEvent.click(screen.getByRole('menuitem', { name: '下移一层' }));
    expect(Number.parseInt(bigFishLayer.style.zIndex, 10)).toBeGreaterThan(0);

    fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
    fireEvent.pointerDown(
      screen.getByAltText('画布图片：拼图素材').closest('button')!,
      {
        button: 0,
        pointerId: 181,
        clientX: 520,
        clientY: 380,
        shiftKey: true,
      },
    );
    fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
      pointerId: 181,
      clientX: 520,
      clientY: 380,
    });
    fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' });
    fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
    fireEvent.click(screen.getByRole('menuitem', { name: '创建组' }));
    await waitFor(() => {
      expect(screen.getAllByText(/已打组/u).length).toBeGreaterThan(0);
    });

    fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
    fireEvent.click(screen.getByRole('menuitem', { name: '解除组' }));
    await waitFor(() => {
      expect(screen.queryByText(/已打组/u)).toBeNull();
    });

    fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 });
    fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
    expect(screen.queryByAltText('画布图片：大鱼素材')).toBeNull();
  });

  it('switches the shared sidebar between assets and layers', () => {
    render(<ImageCanvasEditorView />);

    const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
    expect(within(sidebar).getByText('素材')).toBeTruthy();
    expect(within(sidebar).queryByText('已生成文件')).toBeNull();
    expect(within(sidebar).queryByText('图层')).toBeNull();
    expect(screen.queryByRole('toolbar', { name: '画布主工具栏' })).toBeNull();
    expect(
      screen.queryByRole('complementary', { name: '图层面板' }),
    ).toBeNull();
    expect(screen.queryByRole('dialog', { name: '已生成文件' })).toBeNull();

    fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
    const layersPanel = screen.getByRole('complementary', {
      name: '图片资源栏',
    });
    expect(
      within(layersPanel).getByRole('button', { name: '选择图层拼图素材' }),
    ).toBeTruthy();

    fireEvent.click(screen.getByRole('button', { name: '选择图层大鱼素材' }));

    expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
    expect(
      screen.getByRole('button', { name: '查看大鱼素材图片信息' }),
    ).toBeTruthy();
    expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull();

    fireEvent.click(screen.getByRole('button', { name: '打开素材' }));

    expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy();
  });

  it('adds assets from the sidebar and supports zoom buttons', () => {
    render(<ImageCanvasEditorView />);

    expect(
      screen.getByRole('button', { name: '当前缩放比例 100%' }).className,
    ).toContain('platform-inline-option-button');

    fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
    fireEvent.click(screen.getByRole('menuitem', { name: '放大' }));
    expect(
      screen.getByRole('button', { name: '当前缩放比例 116%' }),
    ).toBeTruthy();

    fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));

    expect(screen.getByAltText('画布图片：声浪素材')).toBeTruthy();
    expect(
      screen.getByRole('complementary', { name: '图片资源栏' }),
    ).toBeTruthy();
  });

  it('saves canvas layout without embedding image payloads in layer snapshots', async () => {
    loadEditorAssetLibraryMock.mockResolvedValueOnce({
      folders: [
        {
          folderId: 'project',
          label: '项目素材',
          sortOrder: 0,
          collapsed: false,
          systemDefault: true,
        },
      ],
      assets: [
        {
          assetId: 'asset-data-heavy',
          folderId: 'project',
          label: '大图素材',
          imageSrc: 'data:image/png;base64,'.concat('a'.repeat(4000)),
          width: 1024,
          height: 768,
          sourceType: 'uploaded',
        },
      ],
    });
    render(<ImageCanvasEditorView />);

    await screen.findByRole('button', { name: '添加大图素材' });
    fireEvent.click(screen.getByRole('button', { name: '添加大图素材' }));

    await waitFor(() => {
      expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
    });
    const lastLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];

    expect(lastLayout.layers).toEqual(
      expect.arrayContaining([
        expect.not.objectContaining({
          src: expect.stringMatching(/^data:image/u),
        }),
      ]),
    );
    expect(lastLayout.layers).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          sourceAssetId: 'asset-data-heavy',
        }),
      ]),
    );
  });

  it('offers Lovart-style zoom menu commands', async () => {
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));

    expect(screen.getByRole('menu', { name: '缩放菜单' })).toBeTruthy();
    expect(
      screen.getByRole('menuitem', { name: '显示画布所有元素' }),
    ).toBeTruthy();

    fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' }));
    expect(
      screen.getByRole('button', { name: /当前缩放比例 \d+%/u }),
    ).toBeTruthy();

    fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
    fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' }));
    expect(
      screen.getByRole('button', { name: '当前缩放比例 50%' }),
    ).toBeTruthy();
  });

  it('shows the Lovart-style minimap and canvas background settings panel', () => {
    render(<ImageCanvasEditorView />);

    const viewport = screen.getByLabelText('画布工作区');
    const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
    const backgroundButton = within(panelToolbar).getByRole('button', {
      name: '画布背景色',
    });

    expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
    expect(backgroundButton.className).toContain('platform-icon-button');
    expect(
      within(panelToolbar).getByRole('button', { name: '切换小地图' }),
    ).toBeTruthy();

    fireEvent.click(backgroundButton);
    const settingsPanel = screen.getByRole('dialog', {
      name: '画布背景设置',
    });
    expect(within(settingsPanel).getByText('画布背景')).toBeTruthy();
    expect(within(settingsPanel).getByLabelText('画布背景色相')).toBeTruthy();
    expect(
      within(settingsPanel).getByLabelText('画布背景十六进制颜色'),
    ).toBeTruthy();

    fireEvent.click(
      within(settingsPanel).getByRole('button', { name: '暖灰' }),
    );

    expect((viewport as HTMLElement).style.backgroundColor).toBe(
      'rgb(243, 240, 234)',
    );

    fireEvent.change(within(settingsPanel).getByLabelText('自定义画布背景色'), {
      target: { value: '#ffffff' },
    });
    expect((viewport as HTMLElement).style.backgroundColor).toBe(
      'rgb(255, 255, 255)',
    );

    const hexInput =
      within(settingsPanel).getByLabelText('画布背景十六进制颜色');
    fireEvent.change(hexInput, { target: { value: '#abc' } });
    expect((hexInput as HTMLInputElement).value).toBe('#aabbcc');
    expect((viewport as HTMLElement).style.backgroundColor).toBe(
      'rgb(170, 187, 204)',
    );

    fireEvent.change(hexInput, { target: { value: '#not-a-color' } });
    expect((hexInput as HTMLInputElement).value).toBe('#not-a-color');
    expect((viewport as HTMLElement).style.backgroundColor).toBe(
      'rgb(170, 187, 204)',
    );

    fireEvent.click(
      within(settingsPanel).getByRole('button', { name: '恢复默认' }),
    );
    expect((viewport as HTMLElement).style.backgroundColor).toBe(
      'rgb(248, 250, 252)',
    );

    fireEvent.keyDown(window, { key: 'Escape' });
    expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull();

    fireEvent.click(
      within(panelToolbar).getByRole('button', { name: '切换小地图' }),
    );
    expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
  });

  it('resets the canvas view without forwarding the click event to fit layers', () => {
    render(<ImageCanvasEditorView />);

    expect(() => {
      fireEvent.click(screen.getByRole('button', { name: '重置画布视图' }));
    }).not.toThrow();
  });

  it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => {
    render(<ImageCanvasEditorView />);

    const viewport = screen.getByLabelText('画布工作区');
    expect(
      screen.getByRole('button', { name: '当前缩放比例 100%' }),
    ).toBeTruthy();

    fireEvent.wheel(viewport, { deltaY: 120, clientX: 400, clientY: 280 });
    expect(
      screen.getByRole('button', { name: '当前缩放比例 100%' }),
    ).toBeTruthy();

    fireEvent.wheel(viewport, {
      deltaY: -120,
      ctrlKey: true,
      clientX: 400,
      clientY: 280,
    });
    expect(
      screen.getByRole('button', { name: '当前缩放比例 110%' }),
    ).toBeTruthy();

    const ctrlWheelEvent = new WheelEvent('wheel', {
      bubbles: true,
      cancelable: true,
      ctrlKey: true,
      deltaY: -120,
      clientX: 400,
      clientY: 280,
    });
    viewport.dispatchEvent(ctrlWheelEvent);
    expect(ctrlWheelEvent.defaultPrevented).toBe(true);
  });

  it('selects multiple canvas layers with shift click', async () => {
    render(<ImageCanvasEditorView />);

    const firstLayer = screen
      .getByAltText('画布图片：拼图素材')
      .closest('button')!;
    const secondLayer = screen
      .getByAltText('画布图片：大鱼素材')
      .closest('button')!;

    fireEvent.pointerDown(firstLayer, {
      button: 0,
      pointerId: 81,
      clientX: 120,
      clientY: 120,
    });
    fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
      pointerId: 81,
      clientX: 120,
      clientY: 120,
    });
    await waitFor(() => {
      expect(
        screen.getByAltText('画布图片：拼图素材').closest('button')?.className,
      ).toContain('image-canvas-editor__layer--selected');
    });
    fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
    fireEvent.pointerDown(secondLayer, {
      button: 0,
      pointerId: 82,
      clientX: 520,
      clientY: 180,
      shiftKey: true,
    });
    fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
      pointerId: 82,
      clientX: 520,
      clientY: 180,
    });
    fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' });

    await waitFor(() => {
      expect(
        screen.getByAltText('画布图片：拼图素材').closest('button')?.className,
      ).toContain('image-canvas-editor__layer--selected');
      expect(
        screen.getByAltText('画布图片：大鱼素材').closest('button')?.className,
      ).toContain('image-canvas-editor__layer--selected');
    });
  });

  it('drags the minimap to move the canvas viewport', () => {
    render(<ImageCanvasEditorView />);

    const minimap = screen.getByRole('button', { name: '画布小地图' });
    vi.spyOn(minimap, 'getBoundingClientRect').mockReturnValue({
      x: 0,
      y: 0,
      left: 0,
      top: 0,
      right: 132,
      bottom: 84,
      width: 132,
      height: 84,
      toJSON: () => ({}),
    });
    dispatchPointerEvent(minimap, 'pointerdown', {
      button: 0,
      pointerId: 71,
      clientX: 120,
      clientY: 72,
    });

    const firstLayer = screen
      .getByAltText('画布图片：拼图素材')
      .closest('button')!;
    expect(Number.parseFloat((firstLayer as HTMLElement).style.left)).toBe(470);
    expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
  });

  it('keeps minimap drag direction stable after pausing and reversing', () => {
    render(<ImageCanvasEditorView />);

    const minimap = screen.getByRole('button', { name: '画布小地图' });
    vi.spyOn(minimap, 'getBoundingClientRect').mockReturnValue({
      x: 0,
      y: 0,
      left: 0,
      top: 0,
      right: 132,
      bottom: 84,
      width: 132,
      height: 84,
      toJSON: () => ({}),
    });
    const world = screen
      .getByLabelText('画布工作区')
      .querySelector('.image-canvas-editor__world') as HTMLElement;
    const readTranslateX = () => {
      const match = /translate\(([-\d.]+)px,/u.exec(world.style.transform);
      return match ? Number(match[1]) : 0;
    };

    dispatchPointerEvent(minimap, 'pointerdown', {
      button: 0,
      pointerId: 72,
      clientX: 60,
      clientY: 42,
    });
    dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
      button: 0,
      pointerId: 72,
      clientX: 120,
      clientY: 42,
    });
    const translateAfterRightDrag = readTranslateX();

    dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
      button: 0,
      pointerId: 72,
      clientX: 90,
      clientY: 42,
    });

    expect(readTranslateX()).toBeGreaterThan(translateAfterRightDrag);
  });

  it('persists layer groups in the canvas layer snapshot', async () => {
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
    fireEvent.pointerDown(
      screen.getByAltText('画布图片：拼图素材').closest('button')!,
      {
        button: 0,
        pointerId: 90,
        clientX: 120,
        clientY: 120,
      },
    );
    fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
      pointerId: 90,
      clientX: 120,
      clientY: 120,
    });
    await waitFor(() => {
      expect(
        screen.getByAltText('画布图片：拼图素材').closest('button')?.className,
      ).toContain('image-canvas-editor__layer--selected');
    });
    fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
    fireEvent.pointerDown(
      screen.getByAltText('画布图片：大鱼素材').closest('button')!,
      {
        button: 0,
        pointerId: 91,
        clientX: 520,
        clientY: 180,
        shiftKey: true,
      },
    );
    fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
      pointerId: 91,
      clientX: 520,
      clientY: 180,
    });
    fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' });
    await waitFor(() => {
      expect(
        screen.getByAltText('画布图片：拼图素材').closest('button')?.className,
      ).toContain('image-canvas-editor__layer--selected');
      expect(
        screen.getByAltText('画布图片：大鱼素材').closest('button')?.className,
      ).toContain('image-canvas-editor__layer--selected');
    });
    fireEvent.click(screen.getByRole('button', { name: '图层打组' }));

    await waitFor(() => {
      expect(screen.getAllByText(/已打组/u)).toHaveLength(2);
    });
    await waitFor(() => {
      expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
        'editor-project-default',
        expect.objectContaining({
          layers: expect.arrayContaining([
            expect.objectContaining({
              title: '拼图素材',
              groupId: expect.stringMatching(/^layer-group-/u),
            }),
            expect.objectContaining({
              title: '大鱼素材',
              groupId: expect.stringMatching(/^layer-group-/u),
            }),
          ]),
        }),
      );
    });
  });

  it('opens a canvas generation frame and composer before creating a generated layer', async () => {
    generateEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
      width: 1024,
      height: 1024,
      sourceType: 'generated',
      prompt: '一张明亮的拼图主视觉',
      actualPrompt: '一张明亮的拼图主视觉',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'editor-real-task-1',
    });
    render(<ImageCanvasEditorView />);

    const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
    fireEvent.click(
      within(bottomToolbar).getByRole('button', { name: '生成工具' }),
    );

    const generateDialog = screen.getByRole('dialog', { name: '生成图片' });
    const initialComposerTop = Number.parseFloat(
      (generateDialog as HTMLElement).style.top,
    );
    expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
    expect(within(generateDialog).getByText('参考图')).toBeTruthy();
    expect(
      within(generateDialog).getByRole('button', { name: '添加参考图' })
        .className,
    ).toContain('bg-white/94');
    expect(
      within(generateDialog).getByRole('button', { name: '添加参考图' })
        .className,
    ).toContain('image-canvas-editor__generation-ref');
    const generatePrompt = screen.getByLabelText('生成提示词');
    expect(generatePrompt.className).toContain('platform-text-field');
    expect(generatePrompt.className).toContain(
      'image-canvas-editor__generation-prompt',
    );
    expect(
      within(generateDialog).getByRole('button', {
        name: '生成比例 1:1 2k 1张',
      }).className,
    ).toContain('platform-inline-option-button');
    expect(
      within(generateDialog).getByRole('button', {
        name: '生成模型 GPT Image',
      }).className,
    ).toContain('platform-inline-option-button');
    expect(
      within(generateDialog).getByRole('button', { name: '生成' }).className,
    ).toContain('platform-button');
    expect(
      within(generateDialog).getByRole('button', { name: '生成' }).className,
    ).toContain('image-canvas-editor__generation-submit');
    expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();

    fireEvent.change(screen.getByLabelText('生成提示词'), {
      target: { value: '一张明亮的拼图主视觉' },
    });
    fireEvent.click(
      within(generateDialog).getByRole('button', { name: '生成' }),
    );

    expect(screen.getByRole('status').textContent).toContain('生成中');
    expect(generateEditorImageMock).toHaveBeenCalledWith({
      prompt: '一张明亮的拼图主视觉',
    });

    await waitFor(() => {
      expect(screen.getByAltText(/画布图片：生成图片/)).toBeTruthy();
    });
    const generatedLayer = screen
      .getByAltText(/画布图片：生成图片/)
      .closest('button')!;
    const anchoredGenerateDialog = screen.getByRole('dialog', {
      name: '生成图片',
    });
    expect(anchoredGenerateDialog).toBeTruthy();
    expect(
      Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
    ).toBeGreaterThan(
      Number.parseFloat((generatedLayer as HTMLElement).style.top),
    );
    expect(
      Number.parseFloat((generatedLayer as HTMLElement).style.top),
    ).toBeLessThan(initialComposerTop);
    expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
    const metadataButtons = screen.getAllByRole('button', {
      name: /查看生成图片 .*图片信息/,
    });
    expect(metadataButtons[0]).toBeTruthy();
    fireEvent.click(metadataButtons[0]!);

    const infoPanel = screen.getByRole('dialog', {
      name: /生成图片 .*图片信息/,
    });
    expect(within(infoPanel).queryByText('Prompt')).toBeNull();
    expect(
      within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
    ).toBeNull();
    expect(within(infoPanel).getByText('生成输入')).toBeTruthy();
    expect(within(infoPanel).getByText('生成提示词')).toBeTruthy();
    expect(within(infoPanel).getByText('一张明亮的拼图主视觉')).toBeTruthy();
  });

  it('drags the generation placeholder and places the generated layer there', async () => {
    generateEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,ZHJhZ2dlZC1mcmFtZQ==',
      width: 1024,
      height: 1024,
      sourceType: 'generated',
      prompt: '拖拽后的生成图',
      actualPrompt: '拖拽后的生成图',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'editor-drag-frame-1',
    });
    render(<ImageCanvasEditorView />);
    await waitFor(() => {
      expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
    });

    fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
    const initialComposerTop = Number.parseFloat(
      (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style
        .top,
    );
    const frame = screen.getByLabelText('图像生成占位图');
    dispatchPointerEvent(frame, 'pointerdown', {
      button: 0,
      pointerId: 61,
      clientX: 500,
      clientY: 260,
    });
    dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
      pointerId: 61,
      clientX: 582,
      clientY: 342,
    });
    dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
      pointerId: 61,
      clientX: 582,
      clientY: 342,
    });
    const draggedComposerTop = Number.parseFloat(
      (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style
        .top,
    );
    expect(draggedComposerTop).toBeGreaterThan(initialComposerTop);
    const draggedFrame = screen.getByLabelText('图像生成占位图') as HTMLElement;
    const draggedFrameCenterX =
      Number.parseFloat(draggedFrame.style.left) +
      Number.parseFloat(draggedFrame.style.width) / 2;
    const draggedFrameCenterY =
      Number.parseFloat(draggedFrame.style.top) +
      Number.parseFloat(draggedFrame.style.height) / 2;
    fireEvent.change(screen.getByLabelText('生成提示词'), {
      target: { value: '拖拽后的生成图' },
    });
    fireEvent.click(screen.getByRole('button', { name: '生成' }));

    await waitFor(() => {
      expect(screen.getByAltText(/画布图片：生成图片/)).toBeTruthy();
    });

    const generatedLayer = screen
      .getByAltText(/画布图片：生成图片/)
      .closest('button')!;
    const anchoredGenerateDialog = screen.getByRole('dialog', {
      name: '生成图片',
    });
    expect(anchoredGenerateDialog).toBeTruthy();
    expect(
      Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
    ).toBeGreaterThan(
      Number.parseFloat((generatedLayer as HTMLElement).style.top),
    );
    expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
    expect(
      Number.parseFloat((generatedLayer as HTMLElement).style.left) +
        Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2,
    ).toBeCloseTo(draggedFrameCenterX, 1);
    expect(
      Number.parseFloat((generatedLayer as HTMLElement).style.top) +
        Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2,
    ).toBeCloseTo(draggedFrameCenterY, 1);
  });

  it('keeps the generation placeholder draggable while the image is generating', async () => {
    let resolveGeneration!: (value: unknown) => void;
    generateEditorImageMock.mockReturnValueOnce(
      new Promise((resolve) => {
        resolveGeneration = resolve;
      }),
    );
    render(<ImageCanvasEditorView />);
    await waitFor(() => {
      expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
    });

    fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
    fireEvent.change(screen.getByLabelText('生成提示词'), {
      target: { value: '生成中继续拖动的图片' },
    });
    fireEvent.click(screen.getByRole('button', { name: '生成' }));

    const frame = screen.getByLabelText('图像生成占位图');
    expect(frame.className).toContain(
      'image-canvas-editor__generation-frame--generating',
    );
    const initialLeft = Number.parseFloat((frame as HTMLElement).style.left);
    const initialTop = Number.parseFloat((frame as HTMLElement).style.top);

    dispatchPointerEvent(frame, 'pointerdown', {
      button: 0,
      pointerId: 67,
      clientX: 500,
      clientY: 260,
    });
    dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
      pointerId: 67,
      clientX: 620,
      clientY: 360,
    });
    dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
      pointerId: 67,
      clientX: 620,
      clientY: 360,
    });

    const draggedFrame = screen.getByLabelText('图像生成占位图');
    expect(
      Number.parseFloat((draggedFrame as HTMLElement).style.left),
    ).toBeGreaterThan(initialLeft);
    expect(
      Number.parseFloat((draggedFrame as HTMLElement).style.top),
    ).toBeGreaterThan(initialTop);

    await act(async () => {
      resolveGeneration({
        imageSrc: 'data:image/png;base64,Z2VuZXJhdGluZy1kcmFn',
        width: 1024,
        height: 1024,
        sourceType: 'generated',
        prompt: '生成中继续拖动的图片',
        actualPrompt: '生成中继续拖动的图片',
        model: 'gpt-image-2',
        provider: 'VectorEngine',
        taskId: 'editor-generating-drag-1',
      });
    });

    await waitFor(() => {
      expect(screen.getByAltText(/画布图片：生成图片/)).toBeTruthy();
    });
    const generatedLayer = screen
      .getByAltText(/画布图片：生成图片/)
      .closest('button')!;
    expect(
      Number.parseFloat((generatedLayer as HTMLElement).style.left) +
        Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2,
    ).toBeCloseTo(
      Number.parseFloat((draggedFrame as HTMLElement).style.left) +
        Number.parseFloat((draggedFrame as HTMLElement).style.width) / 2,
      1,
    );
    expect(
      Number.parseFloat((generatedLayer as HTMLElement).style.top) +
        Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2,
    ).toBeCloseTo(
      Number.parseFloat((draggedFrame as HTMLElement).style.top) +
        Number.parseFloat((draggedFrame as HTMLElement).style.height) / 2,
      1,
    );
  });

  it('hides the generation composer when selecting another image but keeps the placeholder', () => {
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
    expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();

    fireEvent.pointerDown(
      screen.getByAltText('画布图片：拼图素材').closest('button')!,
      {
        button: 0,
        pointerId: 62,
        clientX: 120,
        clientY: 120,
      },
    );

    expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
    expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();

    fireEvent.pointerDown(screen.getByLabelText('图像生成占位图'), {
      button: 0,
      pointerId: 64,
      clientX: 300,
      clientY: 180,
    });

    expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
  });

  it('hides the generation composer when clicking the canvas outside generation controls', () => {
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
    expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();

    fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
      button: 0,
      pointerId: 63,
      clientX: 260,
      clientY: 180,
    });

    expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
    expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
  });

  it('closes the generation composer without removing the placeholder frame', () => {
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
    fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));

    expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
    expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
  });

  it('shows generation errors instead of falling back to mock images', async () => {
    generateEditorImageMock.mockRejectedValueOnce(
      new Error('VectorEngine 未配置'),
    );
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
    fireEvent.change(screen.getByLabelText('生成提示词'), {
      target: { value: '一张真实生成失败的图' },
    });
    fireEvent.click(screen.getByRole('button', { name: '生成' }));

    expect(screen.getByRole('status').textContent).toContain('生成中');

    await waitFor(() => {
      expect(screen.getByRole('alert').textContent).toContain(
        'VectorEngine 未配置',
      );
    });
    expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
    expect(screen.queryByAltText(/画布图片：生成图片/)).toBeNull();
  });

  it('asks the user to log in when real generation is unauthorized', async () => {
    generateEditorImageMock.mockRejectedValueOnce(
      new ApiClientError({
        message: '未授权访问（requestId: web-login-required）',
        status: 401,
        code: 'UNAUTHORIZED',
      }),
    );
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
    fireEvent.change(screen.getByLabelText('生成提示词'), {
      target: { value: '一张需要登录生成的图' },
    });
    fireEvent.click(screen.getByRole('button', { name: '生成' }));

    await waitFor(() => {
      expect(screen.getByRole('alert').textContent).toBe(
        '请先登录后再生成图片',
      );
    });
    expect(screen.queryByText(/requestId/u)).toBeNull();
  });

  it('hides image generation setting panels after generation starts while keeping the preview frame visible', async () => {
    const cases = [
      {
        open: () => {
          fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
          fireEvent.change(screen.getByLabelText('生成提示词'), {
            target: { value: '生成中的普通图片' },
          });
          fireEvent.click(screen.getByRole('button', { name: '生成' }));
        },
        dialogName: '生成图片',
        frameLabel: '图像生成占位图',
      },
      {
        open: () => {
          fireEvent.click(
            within(
              screen.getByRole('toolbar', { name: 'AI画布工具栏' }),
            ).getByRole('button', { name: '生成规范' }),
          );
          fireEvent.click(
            within(
              screen.getByRole('menu', { name: '生成规范类型' }),
            ).getByRole('menuitem', { name: '自定义规范' }),
          );
          fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
            target: { value: '生成中的自定义规范图' },
          });
          fireEvent.click(
            within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
              'button',
              { name: '提交生成规范' },
            ),
          );
        },
        dialogName: '生成规范',
        frameLabel: '规范生成占位图',
      },
      {
        open: () => {
          fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
          fireEvent.change(screen.getByLabelText('角色设定'), {
            target: { value: '生成中的角色形象' },
          });
          fireEvent.click(screen.getByRole('button', { name: '生成' }));
        },
        dialogName: '生成角色形象',
        frameLabel: '角色生成占位图',
      },
    ] as const;

    for (const testCase of cases) {
      generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
      const { unmount } = render(<ImageCanvasEditorView />);
      await waitFor(() => {
        expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
      });

      testCase.open();

      expect(
        screen.queryByRole('dialog', { name: testCase.dialogName }),
      ).toBeNull();
      const frame = screen.getByLabelText(testCase.frameLabel);
      expect(frame.className).toContain(
        'image-canvas-editor__generation-frame--generating',
      );
      expect(within(frame).getByRole('status').textContent).toContain('生成中');

      unmount();
    }
  });

  it('hides the icon material panel after generation starts while keeping the icon preview frame visible', async () => {
    loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-icons-generating',
      title: '图标素材生成中画布',
      viewport: { x: 0, y: 0, scale: 1 },
      layers: [
        {
          layerId: 'layer-icon-spec-generating',
          resourceId: 'resource-icon-spec-generating',
          title: '清爽按钮图标规范',
          src: 'data:image/png;base64,icon-spec-generating',
          x: 80,
          y: 80,
          width: 160,
          height: 160,
          originalWidth: 512,
          originalHeight: 512,
          zIndex: 10,
          sourceType: 'generated',
          assetKind: 'icon-spec',
        },
      ],
      resources: [],
      updatedAt: '2026-06-12T00:00:00.000Z',
    });
    generateEditorIconSpritesheetMock.mockReturnValueOnce(
      new Promise(() => undefined),
    );
    render(<ImageCanvasEditorView />);

    await screen.findByAltText('画布图片：清爽按钮图标规范');
    fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
    const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
    fireEvent.click(
      within(iconPanel).getByRole('button', { name: '图标素材规范' }),
    );
    fireEvent.click(
      within(screen.getByRole('menu', { name: '图标素材规范来源' })).getByRole(
        'menuitem',
        { name: '从画布中选择' },
      ),
    );
    fireEvent.pointerDown(
      screen.getByAltText('画布图片：清爽按钮图标规范').closest('button')!,
      {
        button: 0,
        pointerId: 1260,
        clientX: 120,
        clientY: 120,
      },
    );
    fireEvent.click(
      within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
        'button',
        { name: '生成' },
      ),
    );

    expect(screen.queryByRole('dialog', { name: '生成图标素材' })).toBeNull();
    const frame = screen.getByLabelText('图标素材生成占位图');
    expect(frame.className).toContain(
      'image-canvas-editor__generation-frame--generating',
    );
    expect(within(frame).getByRole('status').textContent).toContain('生成中');
  });

  it('opens character spec generation form and creates a labeled spec layer', async () => {
    generateEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,c3BlYy1yb2xl',
      width: 2048,
      height: 1152,
      sourceType: 'generated',
      prompt: '角色规范提示词',
      actualPrompt: '角色规范提示词',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'editor-spec-role-1',
    });
    render(<ImageCanvasEditorView />);

    const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
    const generationToolLabels = within(bottomToolbar)
      .getAllByRole('button')
      .filter((button) => button.getAttribute('aria-label')?.startsWith('生成'))
      .map((button) => button.getAttribute('aria-label'));
    expect(generationToolLabels).toContain('生成工具');
    expect(generationToolLabels).toContain('生成规范');
    fireEvent.click(
      within(bottomToolbar).getByRole('button', { name: '生成规范' }),
    );

    const specMenu = screen.getByRole('menu', { name: '生成规范类型' });
    expect(
      within(specMenu).getByRole('menuitem', { name: '角色形象规范' }),
    ).toBeTruthy();
    expect(
      within(specMenu).getByRole('menuitem', { name: 'UI素材规范' }),
    ).toBeTruthy();
    expect(
      within(specMenu).getByRole('menuitem', { name: '自定义规范' }),
    ).toBeTruthy();

    fireEvent.click(
      within(specMenu).getByRole('menuitem', { name: '角色形象规范' }),
    );

    const specDialog = screen.getByRole('dialog', { name: '生成规范' });
    expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
    expect(screen.getByText('2048 x 1152')).toBeTruthy();
    expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
      '战棋类RPG玩法',
    );
    expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
      '像素风',
    );
    expect((screen.getByLabelText('头身比') as HTMLSelectElement).value).toBe(
      '3',
    );
    expect((screen.getByLabelText('角色视角') as HTMLInputElement).value).toBe(
      '右向斜侧身站姿，保留少量正面信息，能读到面部轮廓与胸肩结构，禁止生成完全 90 度纯右视图，也禁止生成正面立绘。',
    );
    expect(
      within(specDialog).getByRole('button', { name: '提交生成规范' })
        .textContent,
    ).toContain('消耗5泥点');

    fireEvent.change(screen.getByLabelText('玩法设定'), {
      target: { value: '平台跳跃玩法' },
    });
    fireEvent.change(screen.getByLabelText('美术风格'), {
      target: { value: '低多边形卡通' },
    });
    fireEvent.change(screen.getByLabelText('头身比'), {
      target: { value: '4' },
    });
    fireEvent.change(screen.getByLabelText('角色视角'), {
      target: { value: '左向三分之二侧身站姿' },
    });
    fireEvent.click(
      within(specDialog).getByRole('button', { name: '提交生成规范' }),
    );

    expect(generateEditorImageMock).toHaveBeenCalledWith({
      kind: 'spec',
      model: 'gemini-3.1-flash-image-preview',
      size: '2048x1152',
      prompt: expect.stringContaining('玩法设计：平台跳跃玩法'),
    });
    const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
    expect(prompt).toContain('生成2D 角色美术视觉规范设定图');
    expect(prompt).toContain('美术风格：低多边形卡通');
    expect(prompt).toContain('头身比：4');
    expect(prompt).toContain('视角要求：左向三分之二侧身站姿');

    await waitFor(() => {
      expect(screen.getByAltText(/画布图片：角色形象规范/)).toBeTruthy();
    });
    expect(screen.getByText('规范')).toBeTruthy();
    await waitFor(() => {
      expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
        'editor-project-default',
        expect.objectContaining({
          sourceType: 'generated',
          width: 2048,
          height: 1152,
        }),
      );
    });
    await waitFor(() => {
      expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
        'editor-project-default',
        expect.objectContaining({
          layers: expect.arrayContaining([
            expect.objectContaining({
              title: expect.stringMatching(/角色形象规范/u),
              assetKind: 'spec',
            }),
          ]),
        }),
      );
    });
  });

  it('shows visible titles for character spec, icon spec, and icon spritesheet generation fields', async () => {
    render(<ImageCanvasEditorView />);
    await screen.findByAltText('画布图片：拼图素材');

    fireEvent.click(
      within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
        'button',
        { name: '生成规范' },
      ),
    );
    fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' }));

    const characterSpecDialog = screen.getByRole('dialog', {
      name: '生成规范',
    });
    ['玩法设定', '美术风格', '头身比', '角色视角'].forEach((title) => {
      expect(within(characterSpecDialog).getByText(title)).toBeTruthy();
    });

    fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));

    const iconSpritesheetPanel = screen.getByRole('dialog', {
      name: '生成图标素材',
    });
    expect(
      within(iconSpritesheetPanel).getByRole('button', {
        name: '图标素材规范',
      }),
    ).toBeTruthy();
    expect(within(iconSpritesheetPanel).getByText('素材描述')).toBeTruthy();
    expect(within(iconSpritesheetPanel).getByText('素材描述 1')).toBeTruthy();
    expect(within(iconSpritesheetPanel).getByText('素材描述 6')).toBeTruthy();
    expect(within(iconSpritesheetPanel).getByText('模型')).toBeTruthy();

    fireEvent.click(
      within(iconSpritesheetPanel).getByRole('button', {
        name: '图标素材规范',
      }),
    );
    fireEvent.click(screen.getByRole('menuitem', { name: '新建图标素材规范' }));

    const iconSpecDialog = screen.getByRole('dialog', { name: '生成规范' });
    ['玩法设定', '美术风格'].forEach((title) => {
      expect(within(iconSpecDialog).getByText(title)).toBeTruthy();
    });
  });

  it('defaults character and icon generation to nanobanana2 model options', async () => {
    render(<ImageCanvasEditorView />);
    await screen.findByAltText('画布图片：拼图素材');

    fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
    const characterPanel = screen.getByRole('dialog', {
      name: '生成角色形象',
    });
    expect(within(characterPanel).getByText('画面比例')).toBeTruthy();
    expect(within(characterPanel).getByText('大小尺寸')).toBeTruthy();
    expect(within(characterPanel).getByText('模型')).toBeTruthy();
    expect(
      within(characterPanel).getByRole('button', { name: '1:1' }),
    ).toBeTruthy();
    expect(
      within(characterPanel).getByRole('button', { name: '1K' }),
    ).toBeTruthy();
    expect(
      within(characterPanel).getByRole('button', { name: 'nanobanana2' }),
    ).toBeTruthy();

    fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
    const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
    expect(within(iconPanel).getByText('画面比例')).toBeTruthy();
    expect(within(iconPanel).getByText('大小尺寸')).toBeTruthy();
    expect(within(iconPanel).getByText('模型')).toBeTruthy();
    expect(
      within(iconPanel).getByRole('button', { name: 'nanobanana2' }),
    ).toBeTruthy();
  });

  it('submits character generation with default model and dimension options', async () => {
    generateEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,character-model-options',
      width: 1024,
      height: 1536,
      sourceType: 'generated',
      prompt: '高个子游侠',
      actualPrompt: '高个子游侠',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'character-model-options-1',
    });
    render(<ImageCanvasEditorView />);
    await screen.findByAltText('画布图片：拼图素材');

    fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
    const characterPanel = screen.getByRole('dialog', {
      name: '生成角色形象',
    });
    fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
      target: { value: '高个子游侠' },
    });
    fireEvent.click(
      within(characterPanel).getByRole('button', { name: '生成' }),
    );

    await waitFor(() => {
      expect(generateEditorImageMock).toHaveBeenCalledWith(
        expect.objectContaining({
          kind: 'character',
          prompt: '高个子游侠',
          model: 'gemini-3.1-flash-image-preview',
          aspectRatio: '1:1',
          imageSize: '1K',
        }),
      );
    });
  });

  it('remembers the last selected image model for character and icon generation', async () => {
    generateEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,character-gpt-model',
      width: 1024,
      height: 1024,
      sourceType: 'generated',
      prompt: '蓝衣剑士',
      actualPrompt: '蓝衣剑士',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'character-gpt-model-1',
    });
    generateEditorIconSpritesheetMock.mockResolvedValueOnce({
      spritesheetImageSrc: 'data:image/png;base64,sheet-gpt-model',
      spritesheetWidth: 1024,
      spritesheetHeight: 1024,
      iconImageSrcs: [
        {
          name: '返回按钮',
          imageSrc: 'data:image/png;base64,back',
          width: 128,
          height: 128,
        },
      ],
      prompt: '图标 prompt',
      actualPrompt: '图标 prompt',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'icon-gpt-model-1',
    });
    render(<ImageCanvasEditorView />);
    await screen.findByAltText('画布图片：拼图素材');

    fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
    const characterPanel = screen.getByRole('dialog', {
      name: '生成角色形象',
    });
    fireEvent.click(
      within(characterPanel).getByRole('button', { name: 'gpt-image-2' }),
    );
    fireEvent.click(within(characterPanel).getByRole('button', { name: '2:3' }));
    fireEvent.click(within(characterPanel).getByRole('button', { name: '2K' }));
    fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
      target: { value: '蓝衣剑士' },
    });
    fireEvent.click(
      within(characterPanel).getByRole('button', { name: '生成' }),
    );

    await waitFor(() => {
      expect(generateEditorImageMock).toHaveBeenCalledWith(
        expect.objectContaining({
          kind: 'character',
          prompt: '蓝衣剑士',
          model: 'gpt-image-2',
          aspectRatio: '2:3',
          imageSize: '2K',
        }),
      );
    });

    fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
    const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
    expect(
      within(iconPanel).getByRole('button', { name: 'gpt-image-2' }),
    ).toBeTruthy();
    fireEvent.click(
      within(iconPanel).getByRole('button', { name: '图标素材规范' }),
    );
    fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
    await userEvent.upload(
      screen.getByLabelText('上传图片文件'),
      new File(['icon-spec'], '图标规范.png', { type: 'image/png' }),
    );
    fireEvent.click(
      within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
        'button',
        { name: '生成' },
      ),
    );

    await waitFor(() => {
      expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
        expect.objectContaining({
          model: 'gpt-image-2',
          aspectRatio: '1:1',
          imageSize: '1K',
        }),
      );
    });
  });

  it('keeps the bottom AI toolbar visible while generation panels are open', () => {
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));

    expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
    expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
  });

  it('keeps existing generation placeholders when another bottom generation object is created', async () => {
    render(<ImageCanvasEditorView />);
    await act(async () => {});

    const bottomToolbar = screen.getByRole('toolbar', {
      name: 'AI画布工具栏',
    });
    fireEvent.click(
      within(bottomToolbar).getByRole('button', { name: '生成规范' }),
    );
    fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' }));

    expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
    expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy();

    fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));

    expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
    expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
    expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();

    fireEvent.pointerDown(screen.getByLabelText('规范生成占位图'), {
      button: 0,
      pointerId: 1701,
      clientX: 180,
      clientY: 180,
    });

    expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy();
    expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
  });

  it('keeps archived generation logic using the latest placeholder when another object is active', async () => {
    let resolveGeneration!: (value: unknown) => void;
    generateEditorImageMock.mockReturnValueOnce(
      new Promise((resolve) => {
        resolveGeneration = resolve;
      }),
    );
    render(<ImageCanvasEditorView />);
    await waitFor(() => {
      expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
    });

    fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
    fireEvent.change(screen.getByLabelText('生成提示词'), {
      target: { value: '生成中切换后仍保留位置' },
    });
    fireEvent.click(screen.getByRole('button', { name: '生成' }));

    const originalFrame = screen.getByLabelText('图像生成占位图');
    const originalLeft = Number.parseFloat(
      (originalFrame as HTMLElement).style.left,
    );
    const originalTop = Number.parseFloat(
      (originalFrame as HTMLElement).style.top,
    );

    fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
    expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
    const characterFrame = screen.getByLabelText('角色生成占位图');
    expect(characterFrame).toBeTruthy();

    dispatchPointerEvent(
      screen.getByLabelText('图像生成占位图'),
      'pointerdown',
      {
        button: 0,
        pointerId: 1702,
        clientX: 500,
        clientY: 260,
      },
    );
    dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
      pointerId: 1702,
      clientX: 650,
      clientY: 390,
    });
    dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
      pointerId: 1702,
      clientX: 650,
      clientY: 390,
    });
    const movedFrame = screen.getByLabelText('图像生成占位图');
    const movedLeft = Number.parseFloat((movedFrame as HTMLElement).style.left);
    const movedTop = Number.parseFloat((movedFrame as HTMLElement).style.top);
    expect(movedLeft).toBeGreaterThan(originalLeft);
    expect(movedTop).toBeGreaterThan(originalTop);

    dispatchPointerEvent(characterFrame, 'pointerdown', {
      button: 0,
      pointerId: 1703,
      clientX: 360,
      clientY: 240,
    });
    dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
      pointerId: 1703,
      clientX: 360,
      clientY: 240,
    });
    expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();

    await act(async () => {
      resolveGeneration({
        imageSrc: 'data:image/png;base64,YXJjaGl2ZWQtbG9naWM=',
        width: 1024,
        height: 1024,
        sourceType: 'generated',
        prompt: '生成中切换后仍保留位置',
        actualPrompt: '生成中切换后仍保留位置',
        model: 'gpt-image-2',
        provider: 'VectorEngine',
        taskId: 'editor-archived-generation-1',
      });
    });

    await waitFor(() => {
      expect(screen.getByAltText(/画布图片：生成图片/)).toBeTruthy();
    });
    const generatedLayer = screen
      .getByAltText(/画布图片：生成图片/)
      .closest('button') as HTMLElement;
    const expectedLayerLeft =
      movedLeft +
      Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 -
      512;
    const expectedLayerTop =
      movedTop +
      Number.parseFloat((movedFrame as HTMLElement).style.height) / 2 -
      512;
    expect(Number.parseFloat(generatedLayer.style.left)).toBeCloseTo(
      expectedLayerLeft,
      1,
    );
    expect(Number.parseFloat(generatedLayer.style.top)).toBeCloseTo(
      expectedLayerTop,
      1,
    );
    expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
  });

  it('renders editor popup menus outside clipped local containers', () => {
    render(<ImageCanvasEditorView />);

    const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
    fireEvent.click(
      within(bottomToolbar).getByRole('button', { name: '生成规范' }),
    );
    const specMenu = screen.getByRole('menu', { name: '生成规范类型' });

    expect(bottomToolbar.contains(specMenu)).toBe(false);

    fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
    const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
    fireEvent.click(
      within(characterPanel).getByRole('button', { name: '角色形象规范' }),
    );
    const referenceRow = characterPanel.querySelector(
      '.image-canvas-editor__character-reference-row',
    );
    const sourceMenu = screen.getByRole('menu', { name: '角色形象规范来源' });

    expect(referenceRow?.contains(sourceMenu)).toBe(false);
    expect(sourceMenu.className).toContain('platform-floating-menu--top-start');

    fireEvent.click(
      within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
    );
    const regularReferenceMenu = screen.getByRole('menu', {
      name: '常规参考图来源',
    });
    expect(referenceRow?.contains(regularReferenceMenu)).toBe(false);
    expect(regularReferenceMenu.className).toContain(
      'platform-floating-menu--top-start',
    );
  });

  it('uses Lovart-style reference tiles in the character generation panel', () => {
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
    const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
    const specTile = within(characterPanel).getByRole('button', {
      name: '角色形象规范',
    });
    const uploadTile = within(characterPanel).getByRole('button', {
      name: '上传常规参考图',
    });

    expect(specTile.className).toContain('image-canvas-editor__reference-tile');
    expect(uploadTile.className).toContain(
      'image-canvas-editor__reference-tile',
    );
    expect(
      specTile.querySelector('.image-canvas-editor__reference-tile-visual'),
    ).toBeTruthy();
    expect(
      uploadTile.querySelector('.image-canvas-editor__reference-tile-visual'),
    ).toBeTruthy();
  });

  it('expands the icon panel width as new description items are added', async () => {
    render(<ImageCanvasEditorView />);

    await waitFor(() => {
      expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
    });

    fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));

    const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
    expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(52.8, 1);
    expect(
      iconPanel.querySelector('.image-canvas-editor__icon-description-list'),
    ).toBeTruthy();
    expect(
      iconPanel.querySelector('.image-canvas-editor__icon-description-card'),
    ).toBeTruthy();
    expect(
      iconPanel.querySelector('.image-canvas-editor__icon-spec-card'),
    ).toBeTruthy();

    fireEvent.click(
      within(iconPanel).getByRole('button', { name: '添加素材描述' }),
    );

    expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(61.2, 1);
    expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(7);
  });

  it('hides the active generation panel and clears image selection after canvas background focus', async () => {
    generateEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,Zm9jdXMtY2xlYXI=',
      width: 1024,
      height: 1024,
      sourceType: 'generated',
      prompt: '发光蘑菇角色',
      actualPrompt: '发光蘑菇角色',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'editor-focus-clear-1',
    });
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
    fireEvent.change(screen.getByLabelText('生成提示词'), {
      target: { value: '发光蘑菇角色' },
    });
    fireEvent.click(screen.getByRole('button', { name: '生成' }));

    const generatedImage = await screen.findByAltText(/画布图片：生成图片/u);
    const generatedLayerButton = generatedImage.closest('button')!;
    expect(generatedLayerButton.className).toContain(
      'image-canvas-editor__layer--selected',
    );
    expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();

    fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
      button: 0,
      pointerId: 261,
      clientX: 40,
      clientY: 40,
    });

    expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
    expect(generatedLayerButton.className).not.toContain(
      'image-canvas-editor__layer--selected',
    );
  });

  it('hides a newly created placeholder panel after canvas background focus', () => {
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
    expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
    expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();

    fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
      button: 0,
      pointerId: 262,
      clientX: 40,
      clientY: 40,
    });

    expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
    expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
  });

  it('builds UI spec prompts from two fields and uses 2K landscape generation', async () => {
    generateEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,c3BlYy11aQ==',
      width: 2048,
      height: 1152,
      sourceType: 'generated',
      prompt: 'UI规范提示词',
      actualPrompt: 'UI规范提示词',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'editor-spec-ui-1',
    });
    render(<ImageCanvasEditorView />);

    fireEvent.click(
      within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
        'button',
        { name: '生成规范' },
      ),
    );
    fireEvent.click(screen.getByRole('menuitem', { name: 'UI素材规范' }));

    expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
      '抓娃娃题材的抓大鹅玩法',
    );
    expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
      '毛茸茸',
    );
    fireEvent.change(screen.getByLabelText('玩法设定'), {
      target: { value: '消除类派对玩法' },
    });
    fireEvent.change(screen.getByLabelText('美术风格'), {
      target: { value: '糖果玻璃拟物' },
    });
    fireEvent.click(
      within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
        'button',
        { name: '提交生成规范' },
      ),
    );

    expect(generateEditorImageMock).toHaveBeenCalledWith({
      kind: 'spec',
      model: 'gemini-3.1-flash-image-preview',
      size: '2048x1152',
      prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'),
    });
    const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
    expect(prompt).toContain('玩法设定：消除类派对玩法');
    expect(prompt).toContain('美术风格：糖果玻璃拟物');

    await waitFor(() => {
      expect(screen.getByAltText(/画布图片：UI素材规范/)).toBeTruthy();
    });
    expect(screen.getByText('规范')).toBeTruthy();
  });

  it('uses the custom spec prompt without template rewriting', async () => {
    generateEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,c3BlYy1jdXN0b20=',
      width: 2048,
      height: 1152,
      sourceType: 'generated',
      prompt: '自定义规范提示词',
      actualPrompt: '自定义规范提示词',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'editor-spec-custom-1',
    });
    render(<ImageCanvasEditorView />);

    fireEvent.click(
      within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
        'button',
        { name: '生成规范' },
      ),
    );
    fireEvent.click(screen.getByRole('menuitem', { name: '自定义规范' }));
    fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
      target: { value: '  生成一张武器图标规范展板  ' },
    });
    fireEvent.click(
      within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
        'button',
        { name: '提交生成规范' },
      ),
    );

    expect(generateEditorImageMock).toHaveBeenCalledWith({
      kind: 'spec',
      model: 'gemini-3.1-flash-image-preview',
      size: '2048x1152',
      prompt: '生成一张武器图标规范展板',
    });
    await waitFor(() => {
      expect(screen.getByAltText(/画布图片：自定义规范/)).toBeTruthy();
    });
    expect(screen.getByText('规范')).toBeTruthy();
  });

  it('supports character generation from a picked canvas spec and numbered references', async () => {
    generateEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,Y2hhcmFjdGVy',
      objectKey:
        'generated-character-drafts/editor/character-images/editor-character-1/image.png',
      assetObjectId: 'asset-object-editor-character-1',
      width: 2048,
      height: 2048,
      sourceType: 'generated',
      prompt: '银发游侠，蓝色披风，弓箭手，适合像素风战棋。',
      actualPrompt: '银发游侠，蓝色披风，弓箭手，适合像素风战棋。',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'editor-character-1',
    });
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));

    const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
    expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
    expect(
      within(characterPanel).getByRole('button', { name: '角色形象规范' }),
    ).toBeTruthy();

    fireEvent.click(
      within(characterPanel).getByRole('button', { name: '角色形象规范' }),
    );
    const specSourceMenu = screen.getByRole('menu', {
      name: '角色形象规范来源',
    });
    fireEvent.click(
      within(specSourceMenu).getByRole('menuitem', { name: '从画布中选择' }),
    );
    expect(
      screen.getByText('请选择画布中的图片作为角色形象规范，按 Esc 退出'),
    ).toBeTruthy();

    fireEvent.pointerDown(
      screen.getByAltText('画布图片：拼图素材').closest('button')!,
      {
        button: 0,
        pointerId: 170,
        clientX: 120,
        clientY: 120,
      },
    );
    expect(within(characterPanel).getByText('拼图素材')).toBeTruthy();
    expect(
      screen.queryByText('请选择画布中的图片作为角色形象规范，按 Esc 退出'),
    ).toBeNull();

    const canvasReferenceLayer = screen
      .getByAltText('画布图片：大鱼素材')
      .closest('button')!;
    expect(canvasReferenceLayer.className).not.toContain(
      'image-canvas-editor__layer--selected',
    );
    fireEvent.click(
      within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
    );
    const regularReferenceMenu = screen.getByRole('menu', {
      name: '常规参考图来源',
    });
    fireEvent.click(
      within(regularReferenceMenu).getByRole('menuitem', {
        name: '从画布中选择',
      }),
    );
    expect(
      screen.getByText('请选择画布中的图片作为常规参考图，按 Esc 退出'),
    ).toBeTruthy();
    fireEvent.pointerDown(canvasReferenceLayer, {
      button: 0,
      pointerId: 171,
      clientX: 180,
      clientY: 120,
    });
    expect(
      screen.queryByText('请选择画布中的图片作为常规参考图，按 Esc 退出'),
    ).toBeNull();
    expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
    expect(canvasReferenceLayer.className).not.toContain(
      'image-canvas-editor__layer--selected',
    );
    expect(within(characterPanel).getByText('1')).toBeTruthy();

    fireEvent.click(
      within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
    );
    fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
    await userEvent.upload(
      screen.getByLabelText('上传图片文件'),
      new File(['reference'], '常规参考.png', { type: 'image/png' }),
    );
    await waitFor(() => {
      expect(within(characterPanel).getByText('2')).toBeTruthy();
    });

    fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
      target: { value: '银发游侠，蓝色披风，弓箭手，适合像素风战棋。' },
    });
    fireEvent.click(
      within(characterPanel).getByRole('button', { name: '生成' }),
    );

    expect(generateEditorImageMock).toHaveBeenCalledWith({
      kind: 'character',
      prompt: '银发游侠，蓝色披风，弓箭手，适合像素风战棋。',
      model: 'gemini-3.1-flash-image-preview',
      aspectRatio: '1:1',
      imageSize: '1K',
      referenceImageSrcs: [
        '/creation-type-references/puzzle.webp',
        '/creation-type-references/big-fish.webp',
        expect.stringMatching(/^data:image\/png;base64,/u),
      ],
    });

    await waitFor(() => {
      expect(screen.getByAltText(/画布图片：角色形象/u)).toBeTruthy();
    });
    expect(screen.getByText('角色')).toBeTruthy();
    fireEvent.click(
      screen.getAllByRole('button', {
        name: /查看角色形象 .*图片信息/u,
      })[0]!,
    );
    const characterInfoPanel = screen.getByRole('dialog', {
      name: /角色形象 .*图片信息/u,
    });
    expect(within(characterInfoPanel).queryByText('Prompt')).toBeNull();
    expect(within(characterInfoPanel).getByText('生成输入')).toBeTruthy();
    expect(within(characterInfoPanel).getByText('角色设定')).toBeTruthy();
    expect(
      within(characterInfoPanel).getByText(
        '银发游侠，蓝色披风，弓箭手，适合像素风战棋。',
      ),
    ).toBeTruthy();
    expect(within(characterInfoPanel).getByText('角色形象规范')).toBeTruthy();
    expect(within(characterInfoPanel).getByText('拼图素材')).toBeTruthy();
    expect(within(characterInfoPanel).getByText('常规参考图 1')).toBeTruthy();
    expect(within(characterInfoPanel).getByText('大鱼素材')).toBeTruthy();
    expect(within(characterInfoPanel).getByText('常规参考图 2')).toBeTruthy();
    expect(within(characterInfoPanel).getByText('常规参考.png')).toBeTruthy();
    await waitFor(() => {
      expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
        'editor-project-default',
        expect.objectContaining({
          layers: expect.arrayContaining([
            expect.objectContaining({
              title: expect.stringMatching(/角色形象/u),
              assetKind: 'character',
              objectKey:
                'generated-character-drafts/editor/character-images/editor-character-1/image.png',
              assetObjectId: 'asset-object-editor-character-1',
            }),
          ]),
        }),
      );
    });
    await waitFor(() => {
      expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
        'editor-project-default',
        expect.objectContaining({
          objectKey:
            'generated-character-drafts/editor/character-images/editor-character-1/image.png',
          assetObjectId: 'asset-object-editor-character-1',
        }),
      );
    });
  });

  it('removes the active character generation placeholder with Backspace', async () => {
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));

    expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
    expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();

    await act(async () => {
      fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
    });

    expect(screen.queryByLabelText('角色生成占位图')).toBeNull();
    expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
    expect(screen.getByAltText('画布图片：拼图素材')).toBeTruthy();
  });

  it('opens icon asset generation panel, only picks icon specs, and lays generated icons on canvas', async () => {
    loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-icons',
      title: '图标素材画布',
      viewport: { x: 0, y: 0, scale: 1 },
      layers: [
        {
          layerId: 'layer-plain',
          resourceId: 'resource-plain',
          title: '普通参考图',
          src: 'data:image/png;base64,plain',
          x: 80,
          y: 80,
          width: 120,
          height: 120,
          originalWidth: 512,
          originalHeight: 512,
          zIndex: 10,
          sourceType: 'uploaded',
        },
        {
          layerId: 'layer-icon-spec',
          resourceId: 'resource-icon-spec',
          title: '清爽按钮图标规范',
          src: 'data:image/png;base64,icon-spec',
          x: 240,
          y: 80,
          width: 160,
          height: 120,
          originalWidth: 2048,
          originalHeight: 1152,
          zIndex: 11,
          sourceType: 'generated',
          assetKind: 'icon-spec',
        },
      ],
      resources: [],
      updatedAt: '2026-06-15T00:00:00.000Z',
    });
    generateEditorIconSpritesheetMock.mockResolvedValueOnce({
      spritesheetImageSrc: 'data:image/png;base64,sheet',
      spritesheetWidth: 512,
      spritesheetHeight: 512,
      iconImageSrcs: [
        {
          name: '返回按钮',
          imageSrc: 'data:image/png;base64,back-icon',
          width: 96,
          height: 96,
        },
        {
          name: '设置按钮',
          imageSrc: 'data:image/png;base64,setting-icon',
          width: 96,
          height: 96,
        },
      ],
      prompt: '图标 prompt',
      actualPrompt: '图标 prompt',
      model: 'gemini-3.1-flash-image-preview',
      provider: 'VectorEngine',
      taskId: 'icon-task-1',
    });
    render(<ImageCanvasEditorView />);

    await waitFor(() => {
      expect(screen.getByAltText('画布图片：普通参考图')).toBeTruthy();
      expect(screen.getByAltText('画布图片：清爽按钮图标规范')).toBeTruthy();
    });

    fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));

    const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
    expect(screen.getByLabelText('图标素材生成占位图')).toBeTruthy();
    expect(
      within(iconPanel).getByRole('button', { name: '图标素材规范' }),
    ).toBeTruthy();
    expect(
      (within(iconPanel).getAllByRole('textbox')[0] as HTMLInputElement).value,
    ).toBe('返回按钮');
    expect(
      (within(iconPanel).getAllByRole('textbox')[5] as HTMLInputElement).value,
    ).toBe('冻结按钮');

    fireEvent.click(
      within(iconPanel).getByRole('button', { name: '图标素材规范' }),
    );
    fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
    expect(
      screen.getByText('请选择画布中的图标素材规范，按 Esc 退出'),
    ).toBeTruthy();

    fireEvent.pointerDown(
      screen.getByAltText('画布图片：普通参考图').closest('button')!,
      {
        button: 0,
        pointerId: 180,
        clientX: 100,
        clientY: 100,
      },
    );
    expect(
      within(iconPanel).getByRole('button', { name: '图标素材规范' }),
    ).toBeTruthy();

    fireEvent.pointerDown(
      screen.getByAltText('画布图片：清爽按钮图标规范').closest('button')!,
      {
        button: 0,
        pointerId: 181,
        clientX: 260,
        clientY: 100,
      },
    );
    expect(
      within(iconPanel).getByRole('button', { name: '清爽按钮图标规范' }),
    ).toBeTruthy();
    expect(
      screen.queryByText('请选择画布中的图标素材规范，按 Esc 退出'),
    ).toBeNull();

    const iconDescriptionInputs = within(iconPanel).getAllByRole('textbox');
    const [
      ,
      ,
      iconDescription3,
      iconDescription4,
      iconDescription5,
      iconDescription6,
    ] = iconDescriptionInputs;
    expect(iconDescription3).toBeTruthy();
    expect(iconDescription4).toBeTruthy();
    expect(iconDescription5).toBeTruthy();
    expect(iconDescription6).toBeTruthy();

    fireEvent.change(iconDescription3!, {
      target: { value: '' },
    });
    fireEvent.change(iconDescription4!, {
      target: { value: '' },
    });
    fireEvent.change(iconDescription5!, {
      target: { value: '' },
    });
    fireEvent.change(iconDescription6!, {
      target: { value: '' },
    });
    fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));

    expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith({
      referenceImageSrc: 'data:image/png;base64,icon-spec',
      iconDescriptions: ['返回按钮', '设置按钮'],
      model: 'gemini-3.1-flash-image-preview',
      aspectRatio: '1:1',
      imageSize: '1K',
    });

    await waitFor(() => {
      expect(screen.getByAltText('画布图片：返回按钮')).toBeTruthy();
      expect(screen.getByAltText('画布图片：设置按钮')).toBeTruthy();
    });
    expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull();
    expect(screen.getAllByText('图标')).toHaveLength(2);
    fireEvent.click(
      screen.getAllByRole('button', { name: '查看返回按钮图片信息' })[0]!,
    );
    const iconInfoPanel = screen.getByRole('dialog', {
      name: '返回按钮图片信息',
    });
    expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull();
    expect(within(iconInfoPanel).getByText('生成输入')).toBeTruthy();
    expect(within(iconInfoPanel).getByText('素材描述 1')).toBeTruthy();
    expect(within(iconInfoPanel).getByText('素材描述 2')).toBeTruthy();
    expect(within(iconInfoPanel).getByText('返回按钮')).toBeTruthy();
    expect(within(iconInfoPanel).getByText('设置按钮')).toBeTruthy();
    expect(within(iconInfoPanel).getByText('图标素材规范')).toBeTruthy();
    expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy();
    await waitFor(() => {
      expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
        'editor-project-icons',
        expect.objectContaining({
          layers: expect.arrayContaining([
            expect.objectContaining({
              title: '返回按钮',
              assetKind: 'icon',
            }),
            expect.objectContaining({
              title: '设置按钮',
              assetKind: 'icon',
            }),
          ]),
        }),
      );
    });
  });

  it('exits character generation canvas picking with Escape', () => {
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
    const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
    fireEvent.click(
      within(characterPanel).getByRole('button', { name: '角色形象规范' }),
    );
    fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));

    expect(
      screen.getByText('请选择画布中的图片作为角色形象规范，按 Esc 退出'),
    ).toBeTruthy();

    fireEvent.keyDown(window, { key: 'Escape' });

    expect(
      screen.queryByText('请选择画布中的图片作为角色形象规范，按 Esc 退出'),
    ).toBeNull();
    expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
  });

  it('only exposes character animation generation for character layers and submits the panel payload', async () => {
    loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-character-animation',
      title: '角色动画画布',
      viewport: { x: 0, y: 0, scale: 1 },
      layers: [
        {
          layerId: 'layer-character',
          resourceId: 'resource-character',
          title: '市场老妇人',
          src: 'data:image/png;base64,character',
          x: 160,
          y: 140,
          width: 320,
          height: 320,
          originalWidth: 1024,
          originalHeight: 1024,
          zIndex: 2,
          sourceType: 'generated',
          objectKey:
            'generated-character-drafts/editor/character-images/source/image.png',
          assetKind: 'character',
        },
        {
          layerId: 'layer-prop',
          resourceId: 'resource-prop',
          title: '普通道具',
          src: 'data:image/png;base64,prop',
          x: 520,
          y: 140,
          width: 280,
          height: 220,
          originalWidth: 700,
          originalHeight: 550,
          zIndex: 1,
          sourceType: 'uploaded',
        },
      ],
      resources: [],
      updatedAt: '2026-06-15T00:00:00.000Z',
    });
    generateEditorCharacterAnimationMock.mockResolvedValueOnce({
      taskId: 'character-animation-task-1',
      model: 'seedance2.0',
      prompt: '生成游戏角色动画\n动作描述：\n待机',
      previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
      frames: Array.from({ length: 48 }, (_, index) => ({
        frameIndex: index + 1,
        imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`,
        width: 1024,
        height: 1024,
      })),
      frameCount: 48,
      durationSeconds: 6,
      fps: 8,
      priceMudPoints: 120,
    });
    render(<ImageCanvasEditorView />);

    const propLayer = await screen.findByAltText('画布图片：普通道具');
    fireEvent.click(propLayer.closest('button')!);
    expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
    fireEvent.contextMenu(propLayer.closest('button')!, {
      clientX: 220,
      clientY: 180,
    });
    expect(screen.queryByRole('menuitem', { name: '生成动画' })).toBeNull();

    const characterLayer = screen.getByAltText('画布图片：市场老妇人');
    fireEvent.click(characterLayer.closest('button')!);
    expect(screen.getByText('角色')).toBeTruthy();
    expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
    fireEvent.contextMenu(characterLayer.closest('button')!, {
      clientX: 260,
      clientY: 220,
    });
    expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy();

    fireEvent.click(screen.getByRole('button', { name: '生成动画' }));
    const panel = screen.getByRole('dialog', { name: '角色动画生成面板' });
    expect(within(panel).getByText('40泥点')).toBeTruthy();
    expect(
      (within(panel).getByLabelText('分辨率') as HTMLSelectElement).value,
    ).toBe('480p');
    expect(
      (within(panel).getByLabelText('画面比例') as HTMLSelectElement).value,
    ).toBe('same');
    expect(
      (within(panel).getByLabelText('时长') as HTMLSelectElement).value,
    ).toBe('32');
    for (const actionLabel of [
      '待机',
      '行走',
      '奔跑',
      '跳跃',
      '攻击',
      '受击',
      '倒下',
    ]) {
      expect(
        within(panel).getByRole('button', { name: actionLabel }),
      ).toBeTruthy();
    }
    fireEvent.click(within(panel).getByRole('button', { name: '待机' }));
    expect(
      (within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value,
    ).toContain('待机');
    const longPrompt = '走'.repeat(4100);
    fireEvent.change(within(panel).getByLabelText('动画描述'), {
      target: { value: longPrompt },
    });
    expect(
      (within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value,
    ).toHaveLength(4000);
    const precisePrompt =
      'The elderly market woman gently shifts weight while the basket sways.';
    fireEvent.change(within(panel).getByLabelText('动画描述'), {
      target: { value: precisePrompt },
    });
    expect(
      within(panel).getByLabelText(`生成文本：${precisePrompt}`),
    ).toBeTruthy();
    fireEvent.change(within(panel).getByLabelText('分辨率'), {
      target: { value: '720p' },
    });
    fireEvent.change(within(panel).getByLabelText('画面比例'), {
      target: { value: '16:9' },
    });
    fireEvent.change(within(panel).getByLabelText('时长'), {
      target: { value: '48' },
    });
    expect(within(panel).getByText('120泥点')).toBeTruthy();
    fireEvent.click(within(panel).getByRole('button', { name: '生成' }));

    expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
      expect.objectContaining({
        sourceLayerId: 'layer-character',
        sourceImageSrc:
          'generated-character-drafts/editor/character-images/source/image.png',
        sourceWidth: 1024,
        sourceHeight: 1024,
        resolution: '720p',
        ratio: '16:9',
        frameCount: 48,
        durationSeconds: 6,
        priceMudPoints: 120,
        model: 'seedance2.0',
      }),
    );
    expect(
      generateEditorCharacterAnimationMock.mock.calls[0]?.[0]?.promptText,
    ).toBe(precisePrompt);
    await waitFor(() => {
      expect(within(panel).getByText('已生成 48 帧')).toBeTruthy();
    });
  });

  it('opens quick edit from the floating toolbar with original image as first reference and generates beside the source', async () => {
    loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-quick-edit',
      title: '快速编辑画布',
      viewport: { x: 0, y: 0, scale: 1 },
      layers: [
        {
          layerId: 'layer-quick-source',
          resourceId: 'resource-quick-source',
          title: '魔法森林',
          src: 'data:image/png;base64,c291cmNl',
          x: 120,
          y: 140,
          width: 320,
          height: 240,
          originalWidth: 1536,
          originalHeight: 1024,
          zIndex: 2,
          sourceType: 'generated',
          prompt: '魔法森林原始提示词',
          actualPrompt: '魔法森林原始提示词',
          model: 'gpt-image-2',
          provider: 'VectorEngine',
          taskId: 'source-task-1',
          assetKind: 'spec',
        },
      ],
      resources: [],
      updatedAt: '2026-06-15T00:00:00.000Z',
    });
    generateEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,cXVpY2stZWRpdA==',
      width: 1536,
      height: 1024,
      sourceType: 'generated',
      prompt: '增加萤火虫',
      actualPrompt: '增加萤火虫',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'quick-edit-task-1',
    });
    render(<ImageCanvasEditorView />);

    const sourceImage = await screen.findByAltText('画布图片：魔法森林');
    fireEvent.pointerDown(sourceImage.closest('button')!, {
      button: 0,
      pointerId: 151,
      clientX: 180,
      clientY: 180,
    });
    fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
      pointerId: 151,
      clientX: 180,
      clientY: 180,
    });
    fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));

    const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
    expect(quickPanel.className).toContain(
      'image-canvas-editor__quick-edit-panel',
    );
    expect(within(quickPanel).getByText('魔法森林')).toBeTruthy();
    expect(
      (within(quickPanel).getByLabelText('快速编辑尺寸') as HTMLSelectElement)
        .value,
    ).toBe('1536x1024');
    expect(
      (within(quickPanel).getByLabelText('快速编辑模型') as HTMLSelectElement)
        .value,
    ).toBe('gpt-image-2');
    const references = within(quickPanel).getAllByRole('img');
    expect(references[0]?.getAttribute('src')).toBe(
      'data:image/png;base64,c291cmNl',
    );

    fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
      target: { value: '增加萤火虫' },
    });
    fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));

    await waitFor(() => {
      expect(generateEditorImageMock).toHaveBeenCalledWith({
        prompt: '增加萤火虫',
        size: '1536x1024',
        kind: 'quick-edit',
        model: 'gpt-image-2',
        referenceImageSrcs: ['data:image/png;base64,c291cmNl'],
      });
    });
    await waitFor(() => {
      expect(screen.getByAltText('画布图片：魔法森林 快速编辑')).toBeTruthy();
    });
    const generatedLayer = screen
      .getByAltText('画布图片：魔法森林 快速编辑')
      .closest('button') as HTMLElement;
    expect(Number.parseFloat(generatedLayer.style.left)).toBe(1688);
    expect(Number.parseFloat(generatedLayer.style.top)).toBe(140);
    expect(Number.parseFloat(generatedLayer.style.width)).toBe(1536);
    expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
    await waitFor(() => {
      expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
        'editor-project-quick-edit',
        expect.objectContaining({
          layers: expect.arrayContaining([
            expect.objectContaining({
              title: '魔法森林 快速编辑',
              assetKind: 'spec',
              width: 1536,
              height: 1024,
              originalWidth: 1536,
              originalHeight: 1024,
              x: 1688,
              y: 140,
            }),
          ]),
        }),
      );
    });
  });

  it('opens quick edit from the image context menu', async () => {
    loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-context-quick-edit',
      title: '右键快速编辑画布',
      viewport: { x: 0, y: 0, scale: 1 },
      layers: [
        {
          layerId: 'layer-context-source',
          resourceId: 'resource-context-source',
          title: '右键图片',
          src: 'data:image/png;base64,Y29udGV4dA==',
          x: 80,
          y: 90,
          width: 260,
          height: 260,
          originalWidth: 1024,
          originalHeight: 1024,
          zIndex: 1,
          sourceType: 'uploaded',
          model: 'gpt-image-2',
        },
      ],
      resources: [],
      updatedAt: '2026-06-15T00:00:00.000Z',
    });
    generateEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,Y29udGV4dC1xdWljaw==',
      width: 1024,
      height: 1024,
      sourceType: 'generated',
      prompt: '换成夜晚',
      actualPrompt: '换成夜晚',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'context-quick-task-1',
    });
    render(<ImageCanvasEditorView />);

    const contextImage = await screen.findByAltText('画布图片：右键图片');
    fireEvent.contextMenu(contextImage.closest('button')!, {
      clientX: 260,
      clientY: 220,
    });

    const menu = screen.getByRole('menu', { name: '图片功能面板' });
    expect(
      within(menu).getByRole('menuitem', { name: '快速编辑' }),
    ).toBeTruthy();
    fireEvent.click(within(menu).getByRole('menuitem', { name: '快速编辑' }));

    const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
    expect(within(panel).getByText('右键图片')).toBeTruthy();
    fireEvent.change(within(panel).getByLabelText('快速编辑提示词'), {
      target: { value: '换成夜晚' },
    });
    fireEvent.click(within(panel).getByRole('button', { name: '生成' }));

    await waitFor(() => {
      expect(generateEditorImageMock).toHaveBeenCalledWith(
        expect.objectContaining({
          prompt: '换成夜晚',
          referenceImageSrcs: ['data:image/png;base64,Y29udGV4dA=='],
          size: '1024x1024',
          model: 'gpt-image-2',
          kind: 'quick-edit',
        }),
      );
    });
    await waitFor(() => {
      expect(screen.getByAltText('画布图片：右键图片 快速编辑')).toBeTruthy();
    });
  });

  it('converts non-data-url quick edit source images before submitting references', async () => {
    loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-public-quick-edit',
      title: '公开素材快速编辑画布',
      viewport: { x: 0, y: 0, scale: 1 },
      layers: [
        {
          layerId: 'layer-public-source',
          resourceId: 'resource-public-source',
          title: '公开拼图素材',
          src: '/creation-type-references/puzzle.webp',
          x: 120,
          y: 140,
          width: 320,
          height: 240,
          originalWidth: 640,
          originalHeight: 640,
          zIndex: 2,
          sourceType: 'uploaded',
        },
      ],
      resources: [],
      updatedAt: '2026-06-16T00:00:00.000Z',
    });
    vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
      new Response(new Uint8Array([104, 101, 108, 108, 111]), {
        status: 200,
        headers: {
          'Content-Type': 'image/webp',
        },
      }),
    );
    generateEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,cHVibGljLXF1aWNr',
      width: 640,
      height: 640,
      sourceType: 'generated',
      prompt: '改成陶泥风格',
      actualPrompt: '改成陶泥风格',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'public-quick-edit-task-1',
    });
    render(<ImageCanvasEditorView />);

    const sourceImage = await screen.findByAltText('画布图片：公开拼图素材');
    fireEvent.pointerDown(sourceImage.closest('button')!, {
      button: 0,
      pointerId: 161,
      clientX: 180,
      clientY: 180,
    });
    fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
      pointerId: 161,
      clientX: 180,
      clientY: 180,
    });
    fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));

    const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
    fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
      target: { value: '改成陶泥风格' },
    });
    fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));

    await waitFor(() => {
      expect(generateEditorImageMock).toHaveBeenCalledWith(
        expect.objectContaining({
          prompt: '改成陶泥风格',
          kind: 'quick-edit',
          referenceImageSrcs: ['data:image/webp;base64,aGVsbG8='],
        }),
      );
    });
    expect(globalThis.fetch).toHaveBeenCalledWith(
      '/creation-type-references/puzzle.webp',
      expect.objectContaining({
        signal: undefined,
      }),
    );
  });

  it('switches tools and restores the previous tool after holding Space', async () => {
    const user = userEvent.setup();
    render(<ImageCanvasEditorView />);

    const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
    const selectTool = within(bottomToolbar).getByRole('button', {
      name: '选择工具',
    });
    const textTool = within(bottomToolbar).getByRole('button', {
      name: '文字工具',
    });
    const handTool = within(bottomToolbar).getByRole('button', {
      name: '抓手工具',
    });

    expect(selectTool.getAttribute('aria-pressed')).toBe('true');

    await user.click(textTool);
    expect(textTool.getAttribute('aria-pressed')).toBe('true');

    fireEvent.keyDown(window, { code: 'Space', key: ' ' });
    expect(handTool.getAttribute('aria-pressed')).toBe('true');

    fireEvent.keyUp(window, { code: 'Space', key: ' ' });
    expect(textTool.getAttribute('aria-pressed')).toBe('true');
  });

  it('switches away from hand tool from the bottom toolbar', async () => {
    const user = userEvent.setup();
    render(<ImageCanvasEditorView />);

    const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
    const handTool = within(bottomToolbar).getByRole('button', {
      name: '抓手工具',
    });
    const textTool = within(bottomToolbar).getByRole('button', {
      name: '文字工具',
    });

    await user.click(handTool);
    expect(handTool.getAttribute('aria-pressed')).toBe('true');

    await user.click(textTool);
    expect(textTool.getAttribute('aria-pressed')).toBe('true');
    expect(handTool.getAttribute('aria-pressed')).toBe('false');
  });

  it('pans with the middle mouse button without leaving select mode', async () => {
    render(<ImageCanvasEditorView />);

    const viewport = screen.getByLabelText('画布工作区');
    const middlePointerDown = new MouseEvent('pointerdown', {
      bubbles: true,
      cancelable: true,
      button: 1,
      buttons: 4,
      clientX: 260,
      clientY: 220,
    });
    Object.defineProperty(middlePointerDown, 'pointerId', { value: 11 });
    fireEvent(viewport, middlePointerDown);

    await waitFor(() => {
      expect(viewport.className).toContain(
        'image-canvas-editor__viewport--panning',
      );
    });
    const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
    expect(
      within(bottomToolbar)
        .getByRole('button', { name: '选择工具' })
        .getAttribute('aria-pressed'),
    ).toBe('true');
  });

  it('shows snap guides when dragging a layer near another layer alignment', async () => {
    render(<ImageCanvasEditorView />);

    const puzzleLayer = screen
      .getByAltText('画布图片：拼图素材')
      .closest('button')!;
    dispatchPointerEvent(puzzleLayer, 'pointerdown', {
      button: 0,
      pointerId: 21,
      clientX: 120,
      clientY: 120,
    });
    dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
      pointerId: 21,
      clientX: -60,
      clientY: 180,
    });

    expect(
      screen.getByTestId('image-canvas-editor-snap-guide-vertical'),
    ).toBeTruthy();
    expect(
      screen.getByTestId('image-canvas-editor-snap-guide-horizontal'),
    ).toBeTruthy();
  });

  it('can switch tools after a layer drag started without pointer release', async () => {
    const user = userEvent.setup();
    render(<ImageCanvasEditorView />);

    fireEvent.pointerDown(
      screen.getByAltText('画布图片：拼图素材').closest('button')!,
      {
        button: 0,
        pointerId: 41,
        clientX: 120,
        clientY: 120,
      },
    );
    fireEvent.pointerMove(screen.getByLabelText('画布工作区'), {
      pointerId: 41,
      clientX: 220,
      clientY: 160,
    });

    const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
    const textTool = within(bottomToolbar).getByRole('button', {
      name: '文字工具',
    });
    await user.click(textTool);

    expect(textTool.getAttribute('aria-pressed')).toBe('true');
    expect(
      screen.queryByTestId('image-canvas-editor-snap-guide-vertical'),
    ).toBeNull();
  });

  it('opens generated image info from the corner button and creates a real right-side edit result', async () => {
    generateEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
      width: 1024,
      height: 1024,
      sourceType: 'generated',
      prompt: '一张可修改的生成图',
      actualPrompt: '一张可修改的生成图',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'editor-real-task-2',
    });
    editEditorImageMock.mockResolvedValueOnce({
      imageSrc: 'data:image/png;base64,ZWRpdGVkLWltYWdl',
      width: 1024,
      height: 1024,
      sourceType: 'generated',
      prompt: '把画面改成黄昏光线',
      actualPrompt: '把画面改成黄昏光线',
      model: 'gpt-image-2',
      provider: 'VectorEngine',
      taskId: 'editor-real-edit-1',
    });
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
    fireEvent.change(screen.getByLabelText('生成提示词'), {
      target: { value: '一张可修改的生成图' },
    });
    fireEvent.click(screen.getByRole('button', { name: '生成' }));

    await waitFor(() => {
      expect(screen.getByAltText(/画布图片：生成图片/)).toBeTruthy();
    });
    const generatedLayer = screen
      .getByAltText(/画布图片：生成图片/)
      .closest('button') as HTMLElement;
    expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024);
    expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
    expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();

    const metadataCornerButton = screen.getAllByRole('button', {
      name: /查看生成图片 .*图片信息/,
    })[0];
    if (!metadataCornerButton) {
      throw new Error('metadata corner button should exist');
    }
    expect(metadataCornerButton.className).toContain('bg-black/55');
    expect(metadataCornerButton.className).toContain(
      'image-canvas-editor__metadata-corner',
    );
    fireEvent.click(metadataCornerButton);

    const metadataDialog = screen.getByRole('dialog', {
      name: /生成图片 .*图片信息/,
    });
    expect(metadataDialog).toBeTruthy();
    expect(within(metadataDialog).getByText('图片类型')).toBeTruthy();
    expect(within(metadataDialog).getByText('生成图片')).toBeTruthy();
    expect(within(metadataDialog).queryByText('Prompt')).toBeNull();
    expect(
      within(metadataDialog).queryByRole('button', { name: '复制Prompt' }),
    ).toBeNull();
    expect(within(metadataDialog).getByText('生成输入')).toBeTruthy();
    expect(within(metadataDialog).getByText('生成提示词')).toBeTruthy();
    expect(within(metadataDialog).getByText('一张可修改的生成图')).toBeTruthy();
    expect(within(metadataDialog).getByText('Model')).toBeTruthy();
    expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy();
    expect(within(metadataDialog).queryByText('Size')).toBeNull();
    expect(within(metadataDialog).getByText('Resolution')).toBeTruthy();
    expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy();

    fireEvent.click(screen.getByRole('button', { name: '修改图片' }));
    const editDialog = screen.getByRole('dialog', { name: '修改图片' });
    expect(editDialog).toBeTruthy();
    const editPrompt = screen.getByLabelText('生成提示词');
    expect(editPrompt.className).toContain('platform-text-field');
    expect(editPrompt.className).toContain(
      'image-canvas-editor__generate-prompt',
    );
    fireEvent.change(editPrompt, {
      target: { value: '把画面改成黄昏光线' },
    });
    fireEvent.click(screen.getByRole('button', { name: '修改' }));

    expect(screen.getByRole('status').textContent).toContain('修改中');
    await waitFor(() => {
      expect(editEditorImageMock).toHaveBeenCalledWith({
        prompt: '把画面改成黄昏光线',
        sourceImageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
      });
    });

    await waitFor(() => {
      expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull();
    });
    expect(screen.getByAltText(/画布图片：生成图片 .* 修改结果/)).toBeTruthy();
    fireEvent.click(
      screen.getAllByRole('button', {
        name: /查看生成图片 .* 修改结果图片信息/u,
      })[0]!,
    );
    const editedMetadataDialog = screen.getByRole('dialog', {
      name: /生成图片 .* 修改结果图片信息/u,
    });
    expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull();
    expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy();
    expect(
      within(editedMetadataDialog).getByText('把画面改成黄昏光线'),
    ).toBeTruthy();
    expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy();
    expect(
      within(editedMetadataDialog).getByText(/^生成图片 \d+$/u),
    ).toBeTruthy();
    expect(
      screen.getByRole('button', { name: /当前缩放比例 \d+%/u }),
    ).toBeTruthy();
  });

  it('hides the edit image panel after generation starts while keeping the source preview visible', async () => {
    loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-edit-generating',
      title: '修改图片生成中画布',
      viewport: { x: 0, y: 0, scale: 1 },
      layers: [
        {
          layerId: 'layer-edit-generating-source',
          resourceId: 'resource-edit-generating-source',
          title: '待修改图片',
          src: 'data:image/png;base64,ZWRpdC1nZW5lcmF0aW5n',
          x: 120,
          y: 140,
          width: 320,
          height: 240,
          originalWidth: 1024,
          originalHeight: 768,
          zIndex: 2,
          sourceType: 'generated',
          prompt: '原始提示词',
          actualPrompt: '原始提示词',
          model: 'gpt-image-2',
          provider: 'VectorEngine',
          taskId: 'edit-generating-source-task',
        },
      ],
      resources: [],
      updatedAt: '2026-06-16T00:00:00.000Z',
    });
    editEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
    render(<ImageCanvasEditorView />);

    const sourceImage = await screen.findByAltText('画布图片：待修改图片');
    const sourceLayer = sourceImage.closest('button')!;
    fireEvent.pointerDown(sourceLayer, {
      button: 0,
      pointerId: 171,
      clientX: 180,
      clientY: 180,
    });
    fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
      pointerId: 171,
      clientX: 180,
      clientY: 180,
    });
    fireEvent.click(screen.getByRole('button', { name: '修改图片' }));
    const editDialog = screen.getByRole('dialog', { name: '修改图片' });
    fireEvent.change(within(editDialog).getByLabelText('生成提示词'), {
      target: { value: '改成雨夜灯光' },
    });
    fireEvent.click(within(editDialog).getByRole('button', { name: '修改' }));

    expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull();
    expect(screen.getByAltText('画布图片：待修改图片')).toBeTruthy();
    expect(sourceLayer.className).toContain(
      'image-canvas-editor__layer--generating',
    );
    expect(within(sourceLayer).getByRole('status').textContent).toContain(
      '修改中',
    );
  });

  it('hides the quick edit panel after generation starts while keeping the source preview visible', async () => {
    loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
      projectId: 'editor-project-quick-edit-generating',
      title: '快速编辑生成中画布',
      viewport: { x: 0, y: 0, scale: 1 },
      layers: [
        {
          layerId: 'layer-quick-edit-generating-source',
          resourceId: 'resource-quick-edit-generating-source',
          title: '快速编辑源图',
          src: 'data:image/png;base64,cXVpY2stZWRpdC1nZW5lcmF0aW5n',
          x: 120,
          y: 140,
          width: 320,
          height: 240,
          originalWidth: 1024,
          originalHeight: 768,
          zIndex: 2,
          sourceType: 'uploaded',
          model: 'gpt-image-2',
        },
      ],
      resources: [],
      updatedAt: '2026-06-16T00:00:00.000Z',
    });
    generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
    render(<ImageCanvasEditorView />);

    const sourceImage = await screen.findByAltText('画布图片：快速编辑源图');
    const sourceLayer = sourceImage.closest('button')!;
    fireEvent.pointerDown(sourceLayer, {
      button: 0,
      pointerId: 172,
      clientX: 180,
      clientY: 180,
    });
    fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
      pointerId: 172,
      clientX: 180,
      clientY: 180,
    });
    fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
    const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
    fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
      target: { value: '加一层暖光' },
    });
    fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));

    expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
    expect(screen.getByAltText('画布图片：快速编辑源图')).toBeTruthy();
    expect(sourceLayer.className).toContain(
      'image-canvas-editor__layer--generating',
    );
    expect(within(sourceLayer).getByRole('status').textContent).toContain(
      '生成中',
    );
  });

  it('undoes and redoes canvas layer changes from the panel controls', () => {
    render(<ImageCanvasEditorView />);

    expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
      'disabled',
      true,
    );
    expect(screen.getByRole('button', { name: '重做' })).toHaveProperty(
      'disabled',
      true,
    );

    fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));

    expect(screen.getByAltText('画布图片：声浪素材')).toBeTruthy();
    expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
      'disabled',
      false,
    );
    fireEvent.click(screen.getByRole('button', { name: '撤销' }));

    expect(screen.queryByAltText('画布图片：声浪素材')).toBeNull();
    expect(screen.getByRole('button', { name: '重做' })).toHaveProperty(
      'disabled',
      false,
    );
    fireEvent.click(screen.getByRole('button', { name: '重做' }));

    expect(screen.getByAltText('画布图片：声浪素材')).toBeTruthy();
  });

  it('supports undo and redo keyboard shortcuts inside the editor', () => {
    render(<ImageCanvasEditorView />);

    fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
    expect(screen.getByAltText('画布图片：声浪素材')).toBeTruthy();

    fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
    expect(screen.queryByAltText('画布图片：声浪素材')).toBeNull();

    fireEvent.keyDown(window, {
      key: 'Z',
      code: 'KeyZ',
      ctrlKey: true,
      shiftKey: true,
    });
    expect(screen.getByAltText('画布图片：声浪素材')).toBeTruthy();
  });
});
