/* @vitest-environment jsdom */

import {
  act,
  fireEvent,
  render,
  screen,
  waitFor,
  within,
} from '@testing-library/react';
import { useEffect } from 'react';
import { afterEach, expect, test, vi } from 'vitest';

import type {
  Match3DClickItemRequest,
  Match3DRunSnapshot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import {
  confirmLocalMatch3DClick,
  resolveLocalMatch3DItemTypeCount,
  startLocalMatch3DRun,
} from '../../services/match3d-runtime';
import {
  applyMatch3DRendererCanvasLayout,
  buildMatch3DGeneratedAssetTypeMap,
  buildMatch3DPhysicsEntrySignature,
  buildMatch3DTrayModelSourceMap,
  createMatch3DCannonShape,
  createMatch3DThreeGeometry,
  MATCH3D_EXTRUDED_READABLE_SHAPES,
  MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
  MATCH3D_TRAY_MODEL_TARGET_SIZE,
  measureMatch3DItemPreviewDimension,
  resolveMatch3DBoardDepthPlan,
  resolveMatch3DBoundaryRadius,
  resolveMatch3DColliderBounds,
  resolveMatch3DPhysicsStabilityPlan,
  resolveMatch3DSpawnDelay,
  resolveMatch3DSpawnTimingPlan,
  resolveMatch3DSpawnVisualScale,
  resolveMatch3DSpawnY,
  resolveMatch3DStackTargetY,
  resolveMatch3DTrayPreviewReferenceDimension,
  resolveMatch3DTrayPreviewRotation,
  resolveMatch3DTrayPreviewScale,
} from './Match3DPhysicsBoard';
import {
  MATCH3D_RENDER_ITEM_SCALE,
  resolveRenderableItemFrame,
} from './match3dRuntimePresentation';
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
import { resolveGeometryAsset } from './match3dVisualAssets';

const runtimeAudioFeedback = vi.hoisted(() => ({
  playRuntimeMergeSound: vi.fn(),
}));
const match3dSpritesheetParser = vi.hoisted(() => ({
  loadMatch3DSpritesheetAssetRegions: vi.fn(),
}));

vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
  const actual =
    await importOriginal<typeof import('../../services/runtimeAudioFeedback')>();
  return {
    ...actual,
    playRuntimeMergeSound: runtimeAudioFeedback.playRuntimeMergeSound,
  };
});

vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => {
  const actual =
    await importOriginal<typeof import('../../services/match3dSpritesheetParser')>();
  return {
    ...actual,
    loadMatch3DSpritesheetAssetRegions:
      match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
  };
});

vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
  const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
  return {
    ...actual,
    Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => {
      useEffect(() => {
        const shouldKeep3D =
          (
            globalThis as typeof globalThis & {
              __MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
            }
          ).__MATCH3D_KEEP_3D_TEST_RENDER__ === true;
        if (!shouldKeep3D) {
          onFallback();
        }
      }, [onFallback]);
      return <div data-testid="match3d-physics-board-fallback" />;
    },
    Match3DTrayPreviewBoard: () => (
      <div data-testid="match3d-tray-model-board" />
    ),
  };
});

afterEach(() => {
  delete (
    globalThis as typeof globalThis & {
      __MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
    }
  ).__MATCH3D_KEEP_3D_TEST_RENDER__;
  runtimeAudioFeedback.playRuntimeMergeSound.mockReset();
  match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockReset();
  vi.restoreAllMocks();
});

function mockMatch3DBoardRect() {
  Object.defineProperty(
    screen.getByTestId('match3d-board'),
    'getBoundingClientRect',
    {
      configurable: true,
      value: () => ({
        bottom: 420,
        height: 320,
        left: 40,
        right: 360,
        top: 100,
        width: 320,
        x: 40,
        y: 100,
        toJSON: () => ({}),
      }),
    },
  );
}

function mockMatch3DPointerCapture(element: HTMLElement) {
  Object.defineProperty(element, 'setPointerCapture', {
    configurable: true,
    value: vi.fn(),
  });
  Object.defineProperty(element, 'releasePointerCapture', {
    configurable: true,
    value: vi.fn(),
  });
}

function toMatch3DBoardClientPoint(item: Match3DRunSnapshot['items'][number]) {
  const frame = resolveRenderableItemFrame(item);
  return {
    clientX: 40 + frame.x * 320,
    clientY: 100 + frame.y * 320,
  };
}

function fireMatch3DBoardPointer(
  element: HTMLElement,
  type: 'pointerdown' | 'pointermove' | 'pointerup',
  point: { clientX: number; clientY: number },
  pointerId: number,
) {
  const event = new MouseEvent(type, {
    bubbles: true,
    cancelable: true,
    clientX: point.clientX,
    clientY: point.clientY,
  });
  Object.defineProperty(event, 'pointerId', {
    configurable: true,
    value: pointerId,
  });
  fireEvent(element, event);
}

function renderRuntime(
  run: Match3DRunSnapshot,
  generatedItemAssets: Match3DGeneratedItemAsset[] = [],
) {
  let currentRun = run;
  let authorityRun = run;
  const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => {
    const result = await confirmLocalMatch3DClick(authorityRun, payload);
    authorityRun = result.run;
    return result;
  });
  const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => {
    currentRun = nextRun;
    rerender(
      <Match3DRuntimeShell
        run={currentRun}
        generatedItemAssets={generatedItemAssets}
        onBack={vi.fn()}
        onRestart={vi.fn()}
        onOptimisticRunChange={onOptimisticRunChange}
        onClickItem={onClickItem}
      />,
    );
  });
  const { rerender } = render(
    <Match3DRuntimeShell
      run={currentRun}
      generatedItemAssets={generatedItemAssets}
      onBack={vi.fn()}
      onRestart={vi.fn()}
      onOptimisticRunChange={onOptimisticRunChange}
      onClickItem={onClickItem}
    />,
  );
  return {
    onClickItem,
    onOptimisticRunChange,
  };
}

test('展示圆形空间和 7 格备选栏', () => {
  renderRuntime(startLocalMatch3DRun(4));

  expect(screen.getByTestId('match3d-board')).toBeTruthy();
  expect(screen.getAllByTestId('match3d-tray-slot')).toHaveLength(7);
});

test('顶部 HUD 对齐拼图样式展示关卡名和倒计时', () => {
  const run = startLocalMatch3DRun(4);
  render(
    <Match3DRuntimeShell
      run={run}
      levelName="水果抓大鹅"
      onBack={vi.fn()}
      onRestart={vi.fn()}
      onOptimisticRunChange={vi.fn()}
      onClickItem={vi.fn()}
    />,
  );

  expect(screen.getByText('第 1 关')).toBeTruthy();
  expect(screen.getByText('水果抓大鹅')).toBeTruthy();
  expect(screen.getByText('10:00')).toBeTruthy();
  expect(screen.getByRole('button', { name: '打开抓大鹅设置' })).toBeTruthy();
  expect(screen.queryByRole('button', { name: '重新开始' })).toBeNull();
});

test('抓大鹅右上角设置面板内置重新开始', () => {
  const run = startLocalMatch3DRun(4);
  const onRestart = vi.fn();
  render(
    <Match3DRuntimeShell
      run={run}
      levelName="水果抓大鹅"
      onBack={vi.fn()}
      onRestart={onRestart}
      onOptimisticRunChange={vi.fn()}
      onClickItem={vi.fn()}
    />,
  );

  fireEvent.click(screen.getByRole('button', { name: '打开抓大鹅设置' }));

  const dialog = screen.getByRole('dialog', { name: '抓大鹅设置' });
  expect(within(dialog).getByText('水果抓大鹅')).toBeTruthy();
  expect(within(dialog).getByText('已清除 0/12')).toBeTruthy();
  fireEvent.click(within(dialog).getByRole('button', { name: '重新开始' }));

  expect(onRestart).toHaveBeenCalledTimes(1);
  expect(screen.queryByRole('dialog', { name: '抓大鹅设置' })).toBeNull();
});

test('推荐页抓大鹅运行态隐藏返回按钮和结算返回入口', () => {
  const run: Match3DRunSnapshot = {
    ...startLocalMatch3DRun(4),
    status: 'Won',
  };
  render(
    <Match3DRuntimeShell
      run={run}
      hideBackButton
      onBack={vi.fn()}
      onRestart={vi.fn()}
      onOptimisticRunChange={vi.fn()}
      onClickItem={vi.fn()}
    />,
  );

  expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
  expect(screen.getByRole('button', { name: '再来一局' })).toBeTruthy();
});

test('显示层把可消除物整体半径放大 2 倍且保留相对比例', () => {
  const run = startLocalMatch3DRun(21);
  const firstItemByType = [
    ...new Map(run.items.map((item) => [item.itemTypeId, item])).values(),
  ];
  const smallItem = firstItemByType.reduce((smallest, item) =>
    item.radius < smallest.radius ? item : smallest,
  );
  const largeItem = firstItemByType.reduce((largest, item) =>
    item.radius > largest.radius ? item : largest,
  );

  const smallFrame = resolveRenderableItemFrame(smallItem);
  const largeFrame = resolveRenderableItemFrame(largeItem);

  expect(smallFrame.radius).toBeCloseTo(
    smallItem.radius * MATCH3D_RENDER_ITEM_SCALE,
  );
  expect(largeFrame.radius / smallFrame.radius).toBeCloseTo(
    largeItem.radius / smallItem.radius,
  );
});

test('松手命中可见物品后只提交一次乐观入槽', async () => {
  const run = startLocalMatch3DRun(4);
  const clickableItem = run.items
    .filter((item) => item.clickable)
    .sort((left, right) => right.layer - left.layer)[0];
  expect(clickableItem).toBeTruthy();
  const { onClickItem, onOptimisticRunChange } = renderRuntime(run);
  const board = screen.getByTestId('match3d-board');
  mockMatch3DBoardRect();
  mockMatch3DPointerCapture(board);
  const point = toMatch3DBoardClientPoint(clickableItem!);

  fireMatch3DBoardPointer(board, 'pointerdown', point, 7);

  expect(onClickItem).not.toHaveBeenCalled();
  await waitFor(() => {
    expect(
      screen
        .getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`)
        .getAttribute('aria-pressed'),
    ).toBe('true');
  });

  fireMatch3DBoardPointer(board, 'pointerup', point, 7);

  expect(onOptimisticRunChange).toHaveBeenCalled();
  await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
  await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2));
});

test('松手后的浏览器 click 事件不会重复提交同一个物品', async () => {
  const run = startLocalMatch3DRun(4);
  const clickableItem = run.items
    .filter((item) => item.clickable)
    .sort((left, right) => right.layer - left.layer)[0];
  expect(clickableItem).toBeTruthy();
  const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => {
    return confirmLocalMatch3DClick(run, payload);
  });
  const onOptimisticRunChange = vi.fn();
  render(
    <Match3DRuntimeShell
      run={run}
      onBack={vi.fn()}
      onRestart={vi.fn()}
      onOptimisticRunChange={onOptimisticRunChange}
      onClickItem={onClickItem}
    />,
  );
  const board = screen.getByTestId('match3d-board');
  mockMatch3DBoardRect();
  mockMatch3DPointerCapture(board);
  const point = toMatch3DBoardClientPoint(clickableItem!);

  fireMatch3DBoardPointer(board, 'pointerdown', point, 7);
  fireMatch3DBoardPointer(board, 'pointerup', point, 7);
  fireEvent.click(
    screen.getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`),
  );

  expect(onClickItem).toHaveBeenCalledTimes(1);
  await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2));
});

test('拖动后按松手位置选择单个物品', async () => {
  const baseRun = startLocalMatch3DRun(2);
  const [firstItem, secondItem] = baseRun.items.slice(0, 2);
  expect(firstItem && secondItem).toBeTruthy();
  const run: Match3DRunSnapshot = {
    ...baseRun,
    items: [
      {
        ...firstItem!,
        clickable: true,
        itemInstanceId: 'press-start-item',
        x: 0.35,
        y: 0.5,
        layer: 1,
        radius: 0.06,
      },
      {
        ...secondItem!,
        clickable: true,
        itemInstanceId: 'release-target-item',
        x: 0.68,
        y: 0.5,
        layer: 2,
        radius: 0.06,
      },
      ...baseRun.items.slice(2).map((item) => ({
        ...item,
        clickable: false,
        x: 0.5,
        y: 0.5,
        layer: 0,
      })),
    ],
  };
  const { onClickItem, onOptimisticRunChange } = renderRuntime(run);
  const board = screen.getByTestId('match3d-board');
  mockMatch3DBoardRect();
  mockMatch3DPointerCapture(board);

  fireMatch3DBoardPointer(
    board,
    'pointerdown',
    toMatch3DBoardClientPoint(run.items[0]!),
    9,
  );
  fireMatch3DBoardPointer(
    board,
    'pointermove',
    toMatch3DBoardClientPoint(run.items[1]!),
    9,
  );
  await waitFor(() => {
    expect(
      screen
        .getByTestId('match3d-item-release-target-item')
        .getAttribute('aria-pressed'),
    ).toBe('true');
  });
  fireMatch3DBoardPointer(
    board,
    'pointerup',
    toMatch3DBoardClientPoint(run.items[1]!),
    9,
  );

  await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
  expect(onClickItem.mock.calls[0]?.[0].itemInstanceId).toBe(
    'release-target-item',
  );
  await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2));
});

test('运行态按物品实例稳定使用生成 2D 视角素材', () => {
  const run = startLocalMatch3DRun(1);
  const selectedItem = run.items[0]!;
  const nextRun: Match3DRunSnapshot = {
    ...run,
    items: run.items.map((item, index) =>
      index === 0
        ? {
            ...item,
            state: 'InTray' as const,
            clickable: false,
            traySlotIndex: 0,
          }
        : item,
    ),
    traySlots: run.traySlots.map((slot) =>
      slot.slotIndex === 0
        ? {
            slotIndex: 0,
            itemInstanceId: selectedItem.itemInstanceId,
            itemTypeId: selectedItem.itemTypeId,
            visualKey: selectedItem.visualKey,
          }
        : slot,
    ),
  };
  const generatedItemAssets: Match3DGeneratedItemAsset[] = [
    {
      itemId: 'match3d-item-1',
      itemName: '草莓',
      imageSrc: null,
      imageObjectKey: null,
      imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
        viewId: `view-${viewIndex}`,
        viewIndex,
        imageSrc: `/match3d/strawberry-view-${viewIndex}.png`,
        imageObjectKey: null,
      })),
      modelSrc: null,
      modelObjectKey: null,
      status: 'image_ready',
    },
  ];

  renderRuntime(nextRun, generatedItemAssets);

  const trayImage = screen.getByTestId(
    'match3d-tray-image',
  ) as HTMLImageElement;
  expect(trayImage.src).toContain('/match3d/strawberry-view-1.png');
});

test('运行态按生成素材的相对尺寸缩放场内和托盘图片', () => {
  const run = startLocalMatch3DRun(1);
  const selectedItem = run.items[0]!;
  const nextRun: Match3DRunSnapshot = {
    ...run,
    items: run.items.map((item, index) =>
      index === 0
        ? {
            ...item,
            state: 'InTray' as const,
            clickable: false,
            traySlotIndex: 0,
          }
        : item,
    ),
    traySlots: run.traySlots.map((slot) =>
      slot.slotIndex === 0
        ? {
            slotIndex: 0,
            itemInstanceId: selectedItem.itemInstanceId,
            itemTypeId: selectedItem.itemTypeId,
            visualKey: selectedItem.visualKey,
          }
        : slot,
    ),
  };
  const generatedItemAssets: Match3DGeneratedItemAsset[] = [
    {
      itemId: 'match3d-item-1',
      itemName: '糖果',
      itemSize: '小',
      imageSrc: '/match3d/candy.png',
      imageObjectKey: null,
      imageViews: [],
      modelSrc: null,
      modelObjectKey: null,
      status: 'image_ready',
    },
  ];

  renderRuntime(nextRun, generatedItemAssets);

  expect(
    screen
      .getAllByTestId('match3d-token-image')
      .every((image) => image.style.transform === 'scale(0.58)'),
  ).toBe(true);
  expect(
    (screen.getByTestId('match3d-tray-image') as HTMLElement).style.transform,
  ).toBe('scale(0.58)');
});

test('点击物品乐观插入到物品栏同类后面并后移后续物品', async () => {
  const baseRun = startLocalMatch3DRun(3);
  const [appleBoard, pearTray, appleTray] = baseRun.items.slice(0, 3);
  expect(appleBoard && pearTray && appleTray).toBeTruthy();
  const run: Match3DRunSnapshot = {
    ...baseRun,
    items: [
      {
        ...appleBoard!,
        itemInstanceId: 'apple-3',
        itemTypeId: 'apple',
        visualKey: 'block-red-2x4',
        clickable: true,
        state: 'InBoard',
        x: 0.5,
        y: 0.5,
        layer: 10,
        traySlotIndex: null,
      },
      {
        ...pearTray!,
        itemInstanceId: 'apple-1',
        itemTypeId: 'apple',
        visualKey: 'block-red-2x4',
        clickable: false,
        state: 'InTray',
        traySlotIndex: 0,
      },
      {
        ...appleTray!,
        itemInstanceId: 'apple-2',
        itemTypeId: 'apple',
        visualKey: 'block-red-2x4',
        clickable: false,
        state: 'InTray',
        traySlotIndex: 1,
      },
      {
        ...baseRun.items[3]!,
        itemInstanceId: 'pear-1',
        itemTypeId: 'pear',
        visualKey: 'block-blue-1x2',
        clickable: false,
        state: 'InTray',
        traySlotIndex: 2,
      },
      ...baseRun.items.slice(4).map((item) => ({
        ...item,
        clickable: false,
        state: 'InBoard' as const,
      })),
    ],
    traySlots: baseRun.traySlots.map((slot) => {
      if (slot.slotIndex === 0) {
        return {
          slotIndex: 0,
          itemInstanceId: 'apple-1',
          itemTypeId: 'apple',
          visualKey: 'block-red-2x4',
        };
      }
      if (slot.slotIndex === 1) {
        return {
          slotIndex: 1,
          itemInstanceId: 'apple-2',
          itemTypeId: 'apple',
          visualKey: 'block-red-2x4',
        };
      }
      if (slot.slotIndex === 2) {
        return {
          slotIndex: 2,
          itemInstanceId: 'pear-1',
          itemTypeId: 'pear',
          visualKey: 'block-blue-1x2',
        };
      }
      return { slotIndex: slot.slotIndex };
    }),
  };
  const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
    status: 'Accepted' as const,
    acceptedItemInstanceId: payload.itemInstanceId,
    clearedItemInstanceIds: [],
    run: {
      ...run,
      snapshotVersion: run.snapshotVersion + 1,
    },
  }));
  const onOptimisticRunChange = vi.fn();
  render(
    <Match3DRuntimeShell
      run={run}
      onBack={vi.fn()}
      onRestart={vi.fn()}
      onOptimisticRunChange={onOptimisticRunChange}
      onClickItem={onClickItem}
    />,
  );
  const board = screen.getByTestId('match3d-board');
  mockMatch3DBoardRect();
  mockMatch3DPointerCapture(board);
  Object.defineProperty(screen.getAllByTestId('match3d-tray-slot')[3]!, 'getBoundingClientRect', {
    configurable: true,
    value: () => ({
      bottom: 530,
      height: 56,
      left: 220,
      right: 276,
      top: 474,
      width: 56,
      x: 220,
      y: 474,
      toJSON: () => ({}),
    }),
  });

  const point = toMatch3DBoardClientPoint(run.items[0]!);
  fireMatch3DBoardPointer(board, 'pointerdown', point, 14);
  fireMatch3DBoardPointer(board, 'pointerup', point, 14);

  await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalled());
  const optimisticRun = onOptimisticRunChange.mock.calls[0]?.[0] as
    | Match3DRunSnapshot
    | undefined;
  expect(optimisticRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
    'apple-1',
    'apple-2',
    'apple-3',
    'pear-1',
    null,
    null,
    null,
  ]);
  expect(
    optimisticRun?.items.find((item) => item.itemInstanceId === 'apple-3')
      ?.traySlotIndex,
  ).toBe(2);
});

test('三消确认后物品栏播放合成动画并隐藏权威快照中已清除的槽位', async () => {
  const baseRun = startLocalMatch3DRun(1);
  const [first, second, third, fourth] = baseRun.items.slice(0, 4);
  expect(first && second && third).toBeTruthy();
  const run: Match3DRunSnapshot = {
    ...baseRun,
    totalItemCount: 4,
    items: [
      {
        ...first!,
        itemInstanceId: 'apple-1',
        itemTypeId: 'apple',
        visualKey: 'block-red-2x4',
        state: 'InTray',
        clickable: false,
        traySlotIndex: 0,
      },
      {
        ...second!,
        itemInstanceId: 'apple-2',
        itemTypeId: 'apple',
        visualKey: 'block-red-2x4',
        state: 'InTray',
        clickable: false,
        traySlotIndex: 1,
      },
      {
        ...third!,
        itemInstanceId: 'apple-3',
        itemTypeId: 'apple',
        visualKey: 'block-red-2x4',
        state: 'InBoard',
        clickable: true,
        x: 0.5,
        y: 0.5,
        layer: 10,
        traySlotIndex: null,
      },
      {
        ...(fourth ?? third!),
        itemInstanceId: 'pear-1',
        itemTypeId: 'pear',
        visualKey: 'block-blue-1x2',
        state: 'InTray',
        clickable: false,
        traySlotIndex: 2,
      },
    ],
    traySlots: baseRun.traySlots.map((slot) => {
      if (slot.slotIndex === 0) {
        return {
          slotIndex: 0,
          itemInstanceId: 'apple-1',
          itemTypeId: 'apple',
          visualKey: 'block-red-2x4',
        };
      }
      if (slot.slotIndex === 1) {
        return {
          slotIndex: 1,
          itemInstanceId: 'apple-2',
          itemTypeId: 'apple',
          visualKey: 'block-red-2x4',
        };
      }
      if (slot.slotIndex === 2) {
        return {
          slotIndex: 2,
          itemInstanceId: 'pear-1',
          itemTypeId: 'pear',
          visualKey: 'block-blue-1x2',
        };
      }
      return { slotIndex: slot.slotIndex };
    }),
  };
  const acceptedRun: Match3DRunSnapshot = {
    ...run,
    snapshotVersion: run.snapshotVersion + 1,
    clearedItemCount: 3,
    items: run.items.map((item) =>
      item.itemTypeId === 'apple'
        ? {
            ...item,
            state: 'Cleared' as const,
            clickable: false,
            traySlotIndex: null,
          }
        : { ...item, traySlotIndex: 0 },
    ),
    traySlots: run.traySlots.map((slot) =>
      slot.slotIndex === 0
        ? {
            slotIndex: 0,
            itemInstanceId: 'pear-1',
            itemTypeId: 'pear',
            visualKey: 'block-blue-1x2',
          }
        : { slotIndex: slot.slotIndex },
    ),
  };
  const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
    status: 'Accepted' as const,
    acceptedItemInstanceId: payload.itemInstanceId,
    clearedItemInstanceIds: ['apple-1', 'apple-2', 'apple-3'],
    run: acceptedRun,
  }));
  const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => {
    rerender(
      <Match3DRuntimeShell
        run={nextRun}
        onBack={vi.fn()}
        onRestart={vi.fn()}
        onOptimisticRunChange={onOptimisticRunChange}
        onClickItem={onClickItem}
      />,
    );
  });
  const { rerender } = render(
    <Match3DRuntimeShell
      run={run}
      onBack={vi.fn()}
      onRestart={vi.fn()}
      onOptimisticRunChange={onOptimisticRunChange}
      onClickItem={onClickItem}
    />,
  );
  const board = screen.getByTestId('match3d-board');
  mockMatch3DBoardRect();
  mockMatch3DPointerCapture(board);
  screen.getAllByTestId('match3d-tray-slot').forEach((slot, index) => {
    Object.defineProperty(slot, 'getBoundingClientRect', {
      configurable: true,
      value: () => ({
        bottom: 530,
        height: 56,
        left: 52 + index * 58,
        right: 108 + index * 58,
        top: 474,
        width: 56,
        x: 52 + index * 58,
        y: 474,
        toJSON: () => ({}),
      }),
    });
  });

  const point = toMatch3DBoardClientPoint(run.items[2]!);
  fireMatch3DBoardPointer(board, 'pointerdown', point, 15);
  fireMatch3DBoardPointer(board, 'pointerup', point, 15);

  await waitFor(() =>
    expect(screen.getByTestId('match3d-tray-clear-animation')).toBeTruthy(),
  );
  expect(screen.getAllByTestId('match3d-tray-clear-token')).toHaveLength(3);
  expect(screen.getByTestId('match3d-merge-feedback')).toBeTruthy();
  expect(screen.queryByTestId('match3d-merge-feedback')?.querySelector('svg')).toBeNull();
  expect(runtimeAudioFeedback.playRuntimeMergeSound).toHaveBeenCalledTimes(1);
  const latestRun = onOptimisticRunChange.mock.calls.at(-1)?.[0] as
    | Match3DRunSnapshot
    | undefined;
  expect(latestRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
    'pear-1',
    null,
    null,
    null,
    null,
    null,
    null,
  ]);
});

test('点击物品时播放飞入底部栏位动画并使用第一张物品视图', async () => {
  const run = startLocalMatch3DRun(1);
  const clickableItem = run.items.find((item) => item.clickable)!;
  const generatedItemAssets: Match3DGeneratedItemAsset[] = [
    {
      itemId: 'match3d-item-1',
      itemName: '草莓',
      imageSrc: null,
      imageObjectKey: null,
      imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
        viewId: `view-${viewIndex}`,
        viewIndex,
        imageSrc: `/match3d/strawberry-view-${viewIndex}.png`,
        imageObjectKey: null,
      })),
      modelSrc: null,
      modelObjectKey: null,
      status: 'image_ready',
    },
  ];

  const { onClickItem, onOptimisticRunChange } = renderRuntime(
    run,
    generatedItemAssets,
  );
  const board = screen.getByTestId('match3d-board');
  mockMatch3DBoardRect();
  mockMatch3DPointerCapture(board);
  Object.defineProperty(
    screen.getAllByTestId('match3d-tray-slot')[0]!,
    'getBoundingClientRect',
    {
      configurable: true,
      value: () => ({
        bottom: 530,
        height: 56,
        left: 52,
        right: 108,
        top: 474,
        width: 56,
        x: 52,
        y: 474,
        toJSON: () => ({}),
      }),
    },
  );
  await waitFor(() => {
    expect(
      screen.getAllByTestId('match3d-token-image')[0]?.getAttribute('src'),
    ).toContain('/match3d/strawberry-view-');
  });

  const point = toMatch3DBoardClientPoint(clickableItem);
  fireMatch3DBoardPointer(board, 'pointerdown', point, 12);
  fireMatch3DBoardPointer(board, 'pointerup', point, 12);

  await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
  const flyingImage = screen.getByTestId('match3d-flying-token-image');
  expect(flyingImage.getAttribute('src')).toContain(
    '/match3d/strawberry-view-1.png',
  );
  expect(
    screen
      .getByTestId('match3d-flying-token')
      .style.getPropertyValue('--match3d-fly-dy'),
  ).not.toBe('0px');
  await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2));
});

test('直接点击物品按钮不会绕过松手位置判定', () => {
  const run = startLocalMatch3DRun(1);
  const clickableItem = run.items.find((item) => item.clickable)!;
  const generatedItemAssets: Match3DGeneratedItemAsset[] = [
    {
      itemId: 'match3d-item-1',
      itemName: '草莓',
      imageSrc: '/match3d/strawberry-view-1.png',
      imageObjectKey: null,
      imageViews: [],
      modelSrc: null,
      modelObjectKey: null,
      status: 'image_ready',
    },
  ];

  const { onClickItem, onOptimisticRunChange } = renderRuntime(
    run,
    generatedItemAssets,
  );

  fireEvent.click(
    screen.getByTestId(`match3d-item-${clickableItem.itemInstanceId}`),
  );

  expect(onClickItem).not.toHaveBeenCalled();
  expect(onOptimisticRunChange).not.toHaveBeenCalled();
  expect(screen.queryByTestId('match3d-flying-token-image')).toBeNull();
});

test('3D WebGL 画布锁定 CSS 尺寸，避免高 DPR 手机上溢出中心棋盘', () => {
  const canvas = document.createElement('canvas');

  applyMatch3DRendererCanvasLayout(canvas);

  expect(canvas.style.position).toBe('absolute');
  expect(canvas.style.inset).toBe('0');
  expect(canvas.style.width).toBe('100%');
  expect(canvas.style.height).toBe('100%');
  expect(canvas.style.display).toBe('block');
});

test('3D 物理条目签名随 run 和视觉资源变化，避免旧模型复用到新局', () => {
  const run = startLocalMatch3DRun(10);
  const item = run.items[0]!;
  const sameIdDifferentVisual = {
    ...item,
    visualKey:
      item.visualKey === 'block-red-2x4' ? 'block-blue-1x2' : 'block-red-2x4',
  };

  expect(buildMatch3DPhysicsEntrySignature(run.runId, item)).not.toBe(
    buildMatch3DPhysicsEntrySignature(`${run.runId}-next`, item),
  );
  expect(buildMatch3DPhysicsEntrySignature(run.runId, item)).not.toBe(
    buildMatch3DPhysicsEntrySignature(run.runId, sameIdDifferentVisual),
  );
});

test('生成模型按运行态类型编号映射，并兼容仅 modelObjectKey 的历史素材', () => {
  const run = startLocalMatch3DRun(3);
  run.items = run.items.slice(0, 3).map((item, index) => ({
    ...item,
    itemInstanceId: `generated-model-${index}`,
    itemTypeId:
      index === 0
        ? 'match3d-type-02'
        : index === 1
          ? 'match3d-type-01'
          : 'match3d-type-03',
    visualKey: 'block-red-2x4',
  }));
  const generatedItemAssets: Match3DGeneratedItemAsset[] = [
    {
      itemId: 'match3d-item-1',
      itemName: '草莓',
      status: 'model_ready',
      modelSrc: null,
      modelObjectKey:
        'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
    },
    {
      itemId: 'match3d-item-2',
      itemName: '苹果',
      status: 'model_ready',
      modelSrc:
        '/generated-match3d-assets/session/profile/items/match3d-item-2-item/model/model.glb',
      modelObjectKey: null,
    },
  ];

  const boardMap = buildMatch3DGeneratedAssetTypeMap(run, generatedItemAssets);
  const trayMap = buildMatch3DTrayModelSourceMap(
    run.items,
    [],
    generatedItemAssets,
  );

  expect(boardMap.get('match3d-type-01')?.modelSrc).toBe(
    generatedItemAssets[0]!.modelObjectKey,
  );
  expect(boardMap.get('match3d-type-02')?.modelSrc).toBe(
    generatedItemAssets[1]!.modelSrc,
  );
  expect(trayMap.get('match3d-type-01')).toBe(
    generatedItemAssets[0]!.modelObjectKey,
  );
  expect(trayMap.get('match3d-type-02')).toBe(generatedItemAssets[1]!.modelSrc);
});

test('运行态会先换签 generated 图片素材再渲染局内物品', async () => {
  const run = startLocalMatch3DRun(3);
  const generatedItemAssets: Match3DGeneratedItemAsset[] = [
    {
      itemId: 'match3d-item-1',
      itemName: '草莓',
      imageSrc: null,
      imageObjectKey: null,
      imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
        viewId: `view-${viewIndex}`,
        viewIndex,
        imageSrc: null,
        imageObjectKey: `generated-match3d-assets/session/profile/items/match3d-item-1/views/view-${viewIndex}.png`,
      })),
      status: 'image_ready',
      modelSrc: null,
      modelObjectKey: null,
    },
  ];
  vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
    Promise.resolve(
      new Response(
        JSON.stringify({
          read: {
            signedUrl: 'https://oss.example.com/match3d-view.png',
            expiresAt: new Date(Date.now() + 60_000).toISOString(),
          },
        }),
        {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
        },
      ),
    ),
  );

  render(
    <Match3DRuntimeShell
      run={run}
      generatedItemAssets={generatedItemAssets}
      onBack={vi.fn()}
      onRestart={vi.fn()}
      onOptimisticRunChange={vi.fn()}
      onClickItem={vi.fn()}
    />,
  );

  await waitFor(() => {
    expect(screen.getAllByTestId('match3d-token-image').length).toBeGreaterThan(
      0,
    );
  });
  expect(
    screen.getAllByTestId('match3d-token-image')[0]!.getAttribute('src'),
  ).toBe('https://oss.example.com/match3d-view.png');
});

test('generated 图片素材换签未完成前不显示默认积木', async () => {
  const run = startLocalMatch3DRun(1);
  const generatedItemAssets: Match3DGeneratedItemAsset[] = [
    {
      itemId: 'match3d-item-1',
      itemName: '草莓',
      imageSrc: null,
      imageObjectKey: null,
      imageViews: [
        {
          viewId: 'view-01',
          viewIndex: 1,
          imageSrc: null,
          imageObjectKey:
            'generated-match3d-assets/session/profile/items/match3d-item-1/views/view-01.png',
        },
      ],
      status: 'image_ready',
      modelSrc: null,
      modelObjectKey: null,
    },
  ];
  let resolveFetch: (response: Response) => void = (_response: Response) => {
    throw new Error('read-url fetch was not started');
  };
  vi.spyOn(globalThis, 'fetch').mockImplementation(
    () =>
      new Promise<Response>((resolve) => {
        resolveFetch = resolve;
      }),
  );

  render(
    <Match3DRuntimeShell
      run={run}
      generatedItemAssets={generatedItemAssets}
      onBack={vi.fn()}
      onRestart={vi.fn()}
      onOptimisticRunChange={vi.fn()}
      onClickItem={vi.fn()}
    />,
  );

  expect(screen.queryByTestId('match3d-token-image')).toBeNull();
  expect(screen.queryAllByTestId(/^match3d-visual-/u)).toHaveLength(0);

  resolveFetch(
    new Response(
      JSON.stringify({
        read: {
          signedUrl: 'https://oss.example.com/match3d-view.png',
          expiresAt: new Date(Date.now() + 60_000).toISOString(),
        },
      }),
      {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      },
    ),
  );

  await waitFor(() => {
    expect(
      screen.getAllByTestId('match3d-token-image')[0]?.getAttribute('src'),
    ).toBe('https://oss.example.com/match3d-view.png');
  });
});

test('同一批 generated 图片素材在重启 run 时保留已解析地址', async () => {
  const firstRun = startLocalMatch3DRun(1);
  const secondRun: Match3DRunSnapshot = {
    ...startLocalMatch3DRun(1),
    profileId: firstRun.profileId,
    items: firstRun.items.map((item) => ({
      ...item,
      itemInstanceId: `${item.itemInstanceId}-restart`,
    })),
  };
  const generatedItemAssets: Match3DGeneratedItemAsset[] = [
    {
      itemId: 'match3d-item-1',
      itemName: '草莓',
      imageSrc: null,
      imageObjectKey: null,
      imageViews: [
        {
          viewId: 'view-01',
          viewIndex: 1,
          imageSrc: null,
          imageObjectKey:
            'generated-match3d-assets/session/profile/items/match3d-item-1/views/view-01.png',
        },
      ],
      status: 'image_ready',
      modelSrc: null,
      modelObjectKey: null,
    },
  ];
  vi.spyOn(globalThis, 'fetch').mockResolvedValue(
    new Response(
      JSON.stringify({
        read: {
          signedUrl: 'https://oss.example.com/match3d-view.png',
          expiresAt: new Date(Date.now() + 60_000).toISOString(),
        },
      }),
      {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      },
    ),
  );

  const { rerender } = render(
    <Match3DRuntimeShell
      run={firstRun}
      generatedItemAssets={generatedItemAssets}
      onBack={vi.fn()}
      onRestart={vi.fn()}
      onOptimisticRunChange={vi.fn()}
      onClickItem={vi.fn()}
    />,
  );

  await waitFor(() => {
    expect(
      screen.getAllByTestId('match3d-token-image')[0]?.getAttribute('src'),
    ).toBe('https://oss.example.com/match3d-view.png');
  });

  await act(async () => {
    rerender(
      <Match3DRuntimeShell
        run={secondRun}
        generatedItemAssets={generatedItemAssets}
        onBack={vi.fn()}
        onRestart={vi.fn()}
        onOptimisticRunChange={vi.fn()}
        onClickItem={vi.fn()}
      />,
    );
  });

  expect(
    screen.getAllByTestId('match3d-token-image')[0]?.getAttribute('src'),
  ).toBe('https://oss.example.com/match3d-view.png');
  expect(screen.queryAllByTestId(/^match3d-visual-/u)).toHaveLength(0);
});

test('运行态按 generated itemId 编号映射到后端 match3d-type 类型', async () => {
  const baseRun = startLocalMatch3DRun(3);
  const baseTypeIds = [
    ...new Set(baseRun.items.map((item) => item.itemTypeId)),
  ];
  const run: Match3DRunSnapshot = {
    ...baseRun,
    items: baseRun.items.map((item) =>
      item.itemTypeId === baseTypeIds[0]
        ? { ...item, itemTypeId: 'match3d-type-01' }
        : item.itemTypeId === baseTypeIds[1]
          ? { ...item, itemTypeId: 'match3d-type-02' }
          : item,
    ),
  };
  const typeOneItem = run.items.find(
    (item) => item.itemTypeId === 'match3d-type-01',
  );
  expect(typeOneItem).toBeTruthy();
  const generatedItemAssets: Match3DGeneratedItemAsset[] = [
    {
      itemId: 'match3d-item-1',
      itemName: '樱桃',
      imageSrc: null,
      imageObjectKey: null,
      imageViews: [
        {
          viewId: 'view-01',
          viewIndex: 1,
          imageSrc: '/match3d/cherry-view-01.png',
          imageObjectKey: null,
        },
      ],
      status: 'image_ready',
      modelSrc: null,
      modelObjectKey: null,
    },
    {
      itemId: 'match3d-item-2',
      itemName: '苹果',
      imageSrc: null,
      imageObjectKey: null,
      imageViews: [
        {
          viewId: 'view-01',
          viewIndex: 1,
          imageSrc: '/match3d/apple-view-01.png',
          imageObjectKey: null,
        },
      ],
      status: 'image_ready',
      modelSrc: null,
      modelObjectKey: null,
    },
  ];

  renderRuntime(run, generatedItemAssets);

  const token = screen.getByTestId(
    `match3d-item-${typeOneItem!.itemInstanceId}`,
  );
  await waitFor(() => {
    expect(token.querySelector('img')?.getAttribute('src')).toContain(
      '/match3d/cherry-view-01.png',
    );
  });
});

test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
  const run = startLocalMatch3DRun(3);
  const generatedItemAssets: Match3DGeneratedItemAsset[] = [
    {
      itemId: 'match3d-item-1',
      itemName: '草莓',
      imageSrc: null,
      imageObjectKey: null,
      imageViews: [],
      status: 'image_ready',
      modelSrc: null,
      modelObjectKey: null,
      backgroundAsset: {
        prompt: '果园纯背景',
        imageSrc: null,
        imageObjectKey: null,
        containerPrompt: '果园浅盘容器',
        containerImageSrc: null,
        containerImageObjectKey:
          'generated-match3d-assets/session/profile/ui-container/task/container.png',
        status: 'image_ready',
        error: null,
      },
    },
  ];
  vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
    Promise.resolve(
      new Response(
        JSON.stringify({
          read: {
            signedUrl: 'https://oss.example.com/match3d-container.png',
            expiresAt: new Date(Date.now() + 60_000).toISOString(),
          },
        }),
        {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
        },
      ),
    ),
  );

  renderRuntime(run, generatedItemAssets);

  await waitFor(() => {
    expect(
      screen.getByTestId('match3d-container-image').getAttribute('src'),
    ).toBe('https://oss.example.com/match3d-container.png');
  });
  fireEvent.load(screen.getByTestId('match3d-container-image'));
  const containerImage = screen.getByTestId(
    'match3d-container-image',
  ) as HTMLImageElement;
  expect(containerImage.className).toContain('w-[min(116vw,42rem)]');
  expect(containerImage.className).toContain('h-auto');
  expect(containerImage.className).toContain('left-1/2');
  expect(containerImage.className).toContain('top-[54%]');
  expect(containerImage.className).toContain('-translate-x-1/2');
  expect(screen.getByTestId('match3d-board').className).toContain(
    'bg-transparent',
  );
  expect(screen.getByTestId('match3d-board').className).not.toContain(
    'rounded-full',
  );
});

test('运行态没有生成容器时使用透明参考容器兜底', async () => {
  const run = startLocalMatch3DRun(3);

  renderRuntime(run, []);

  let containerImage!: HTMLImageElement;
  await waitFor(() => {
    containerImage = screen.getByTestId(
      'match3d-container-image',
    ) as HTMLImageElement;
    expect(containerImage.getAttribute('src')).toBe(
      '/match3d-background-references/pot-fused-reference.png',
    );
  });
  fireEvent.load(containerImage);
  expect(screen.getByTestId('match3d-board').className).toContain(
    'bg-transparent',
  );
  expect(screen.getByTestId('match3d-board').className).not.toContain(
    'rounded-full',
  );
});

test('容器图换签失败时使用透明参考容器兜底', async () => {
  const run = startLocalMatch3DRun(3);
  const generatedItemAssets: Match3DGeneratedItemAsset[] = [
    {
      itemId: 'match3d-item-1',
      itemName: '草莓',
      imageSrc: null,
      imageObjectKey: null,
      imageViews: [],
      status: 'image_ready',
      modelSrc: null,
      modelObjectKey: null,
      backgroundAsset: {
        prompt: '果园纯背景',
        imageSrc: null,
        imageObjectKey: null,
        containerPrompt: '果园浅盘容器',
        containerImageSrc: null,
        containerImageObjectKey:
          'generated-match3d-assets/session/profile/ui-container/failing-task/container.png',
        status: 'image_ready',
        error: null,
      },
    },
  ];
  vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('read-url failed'));

  renderRuntime(run, generatedItemAssets);

  await waitFor(() => {
    expect(globalThis.fetch).toHaveBeenCalled();
  });
  await waitFor(() => {
    expect(
      screen.getByTestId('match3d-container-image').getAttribute('src'),
    ).toBe('/match3d-background-references/pot-fused-reference.png');
  });
  fireEvent.load(screen.getByTestId('match3d-container-image'));
  expect(screen.getByTestId('match3d-board').className).toContain(
    'bg-transparent',
  );
  expect(screen.getByTestId('match3d-board').className).not.toContain(
    'rounded-full',
  );
});

test('运行态会从顶层 UI 资产加载背景和容器图', async () => {
  const run = startLocalMatch3DRun(3);
  vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
    const url = String(input);
    const signedUrl = url.includes('ui-container')
      ? 'https://oss.example.com/match3d-container.png'
      : 'https://oss.example.com/match3d-background.png';
    return Promise.resolve(
      new Response(
        JSON.stringify({
          read: {
            signedUrl,
            expiresAt: new Date(Date.now() + 60_000).toISOString(),
          },
        }),
        {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
        },
      ),
    );
  });

  render(
    <Match3DRuntimeShell
      run={run}
      generatedItemAssets={[]}
      generatedBackgroundAsset={{
        prompt: '果园纯背景',
        imageSrc: null,
        imageObjectKey:
          'generated-match3d-assets/session/profile/background/task/background.png',
        containerPrompt: '果园浅盘容器',
        containerImageSrc: null,
        containerImageObjectKey:
          'generated-match3d-assets/session/profile/ui-container/task/container.png',
        status: 'image_ready',
        error: null,
      }}
      onBack={vi.fn()}
      onRestart={vi.fn()}
      onOptimisticRunChange={vi.fn()}
      onClickItem={vi.fn()}
    />,
  );

  await waitFor(() => {
    expect(
      screen.getByTestId('match3d-background-image').getAttribute('src'),
    ).toBe('https://oss.example.com/match3d-background.png');
    expect(
      screen.getByTestId('match3d-container-image').getAttribute('src'),
    ).toBe('https://oss.example.com/match3d-container.png');
  });
  fireEvent.load(screen.getByTestId('match3d-container-image'));
  expect(screen.getByTestId('match3d-board').className).toContain(
    'bg-transparent',
  );
});

test('运行态从UI spritesheet裁切按钮并映射到原UI位置', async () => {
  const run = startLocalMatch3DRun(3);
  match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
    ['返回', '设置', '方格', '移出', '凑齐', '打乱'].map((label, index) => ({
      label,
      x: index * 10,
      y: 0,
      width: 8,
      height: 8,
      sheetWidth: 64,
      sheetHeight: 64,
      imageSrc: `data:image/png;base64,${label}`,
    })),
  );

  render(
    <Match3DRuntimeShell
      run={run}
      generatedItemAssets={[]}
      generatedBackgroundAsset={{
        prompt: '果园纯背景',
        imageSrc: '/match3d/background.png',
        imageObjectKey: null,
        uiSpritesheetPrompt: '果园UI',
        uiSpritesheetImageSrc:
          '/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
        uiSpritesheetImageObjectKey: null,
        status: 'image_ready',
        error: null,
      }}
      onBack={vi.fn()}
      onRestart={vi.fn()}
      onOptimisticRunChange={vi.fn()}
      onClickItem={vi.fn()}
    />,
  );

  await waitFor(() => {
    expect(
      screen.getByTestId('match3d-ui-sprite-back').getAttribute('src'),
    ).toBe('data:image/png;base64,返回');
    expect(
      screen.getByTestId('match3d-ui-sprite-settings').getAttribute('src'),
    ).toBe('data:image/png;base64,设置');
    expect(
      screen.getByTestId('match3d-ui-sprite-prop-remove').getAttribute('src'),
    ).toBe('data:image/png;base64,移出');
    expect(
      screen.getByTestId('match3d-ui-sprite-prop-collect').getAttribute('src'),
    ).toBe('data:image/png;base64,凑齐');
    expect(
      screen.getByTestId('match3d-ui-sprite-prop-shuffle').getAttribute('src'),
    ).toBe('data:image/png;base64,打乱');
  });
  expect(
    match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
  ).toHaveBeenCalledWith(
    expect.objectContaining({
      labels: ['返回', '设置', '方格', '移出', '凑齐', '打乱'],
      maxRegions: 6,
      source: '/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
    }),
  );
});

test('运行态不把兼容写入的UI spritesheet当中心容器图', async () => {
  const run = startLocalMatch3DRun(3);
  match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
    [],
  );

  render(
    <Match3DRuntimeShell
      run={run}
      generatedItemAssets={[]}
      generatedBackgroundAsset={{
        prompt: '果园纯背景',
        imageSrc: '/match3d/background.png',
        imageObjectKey: null,
        uiSpritesheetPrompt: '果园UI',
        uiSpritesheetImageSrc:
          '/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
        uiSpritesheetImageObjectKey: null,
        containerPrompt: '兼容UI素材',
        containerImageSrc:
          '/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
        containerImageObjectKey: null,
        status: 'image_ready',
        error: null,
      }}
      onBack={vi.fn()}
      onRestart={vi.fn()}
      onOptimisticRunChange={vi.fn()}
      onClickItem={vi.fn()}
    />,
  );

  await waitFor(() => {
    expect(
      screen.getByTestId('match3d-container-image').getAttribute('src'),
    ).toBe('/match3d-background-references/pot-fused-reference.png');
  });
});

test('运行态缺少imageViews时从物品spritesheet解析五视角图片', async () => {
  const run = startLocalMatch3DRun(1);
  const selectedItem = run.items[0]!;
  const nextRun: Match3DRunSnapshot = {
    ...run,
    items: run.items.map((item, index) =>
      index === 0
        ? {
            ...item,
            state: 'InTray' as const,
            clickable: false,
            traySlotIndex: 0,
          }
        : item,
    ),
    traySlots: run.traySlots.map((slot) =>
      slot.slotIndex === 0
        ? {
            slotIndex: 0,
            itemInstanceId: selectedItem.itemInstanceId,
            itemTypeId: selectedItem.itemTypeId,
            visualKey: selectedItem.visualKey,
          }
        : slot,
    ),
  };
  match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockImplementation(
    async ({ source }: { source: string }) => {
      if (source.includes('item-spritesheet')) {
        return Array.from({ length: 100 }, (_, index) => ({
          label: `素材${index + 1}`,
          x: index * 2,
          y: 0,
          width: 2,
          height: 2,
          sheetWidth: 200,
          sheetHeight: 2,
          imageSrc: `data:image/png;base64,item-${index + 1}`,
        }));
      }
      return [];
    },
  );

  render(
    <Match3DRuntimeShell
      run={nextRun}
      generatedItemAssets={[
        {
          itemId: 'match3d-item-1',
          itemName: '草莓',
          imageSrc: null,
          imageObjectKey: null,
          imageViews: [],
          modelSrc: null,
          modelObjectKey: null,
          status: 'image_ready',
        },
      ]}
      generatedBackgroundAsset={{
        prompt: '果园纯背景',
        imageSrc: '/match3d/background.png',
        imageObjectKey: null,
        itemSpritesheetPrompt: '果园物品',
        itemSpritesheetImageSrc:
          '/generated-match3d-assets/session/profile/item-spritesheet/items.png',
        itemSpritesheetImageObjectKey: null,
        status: 'image_ready',
        error: null,
      }}
      onBack={vi.fn()}
      onRestart={vi.fn()}
      onOptimisticRunChange={vi.fn()}
      onClickItem={vi.fn()}
    />,
  );

  await waitFor(() => {
    expect(
      (screen.getByTestId('match3d-tray-image') as HTMLImageElement).src,
    ).toBe('data:image/png;base64,item-1');
  });
});

test('运行态从任意素材读取作品级背景音乐并换签播放', async () => {
  const run = startLocalMatch3DRun(3);
  const playSpy = vi
    .spyOn(HTMLMediaElement.prototype, 'play')
    .mockResolvedValue(undefined);
  vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
    const url = String(input);
    const signedUrl = url.includes('legacyPublicPath')
      ? 'https://oss.example.com/match3d-music.mp3'
      : 'https://oss.example.com/match3d-view.png';
    return Promise.resolve(
      new Response(
        JSON.stringify({
          read: {
            signedUrl,
            expiresAt: new Date(Date.now() + 60_000).toISOString(),
          },
        }),
        {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
        },
      ),
    );
  });
  const generatedItemAssets: Match3DGeneratedItemAsset[] = [
    {
      itemId: 'match3d-item-1',
      itemName: '草莓',
      imageSrc: '/match3d/strawberry.png',
      imageObjectKey: null,
      imageViews: [],
      status: 'image_ready',
      modelSrc: null,
      modelObjectKey: null,
    },
    {
      itemId: 'match3d-item-2',
      itemName: '苹果',
      imageSrc: '/match3d/apple.png',
      imageObjectKey: null,
      imageViews: [],
      status: 'image_ready',
      modelSrc: null,
      modelObjectKey: null,
      backgroundMusic: {
        taskId: 'music-task-1',
        provider: 'vector-engine-suno',
        assetObjectId: 'asset-music-1',
        assetKind: 'match3d_background_music',
        audioSrc: '/generated-match3d-assets/audio/music.mp3',
        prompt: '',
        title: '果园轻舞',
        updatedAt: '2026-05-14T00:00:00.000Z',
      },
    },
  ];

  renderRuntime(run, generatedItemAssets);

  await waitFor(() => {
    expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
      'https://oss.example.com/match3d-music.mp3',
    );
  });
  await waitFor(() => expect(playSpy).toHaveBeenCalled());
});

test('本地试玩按难度档位生成类型并兼容历史硬核消除数', () => {
  const smallRun = startLocalMatch3DRun(12);
  const hardRun = startLocalMatch3DRun(20);
  const countTypes = (run: Match3DRunSnapshot) =>
    new Set(run.items.map((item) => item.itemTypeId)).size;

  expect(resolveLocalMatch3DItemTypeCount(8)).toBe(3);
  expect(resolveLocalMatch3DItemTypeCount(12)).toBe(9);
  expect(resolveLocalMatch3DItemTypeCount(16)).toBe(15);
  expect(resolveLocalMatch3DItemTypeCount(20)).toBe(20);
  expect(resolveLocalMatch3DItemTypeCount(21)).toBe(20);
  expect(countTypes(smallRun)).toBe(9);
  expect(countTypes(hardRun)).toBe(20);
  expect(hardRun.clearCount).toBe(21);
  expect(hardRun.items).toHaveLength(63);
});

test('硬核档位生成不重复积木视觉签名', () => {
  const run = startLocalMatch3DRun(21);
  const firstItemByType = new Map(
    run.items.map((item) => [item.itemTypeId, item]),
  );
  const visualKeys = new Set(
    [...firstItemByType.values()].map((item) => item.visualKey),
  );
  const signatures = new Set(
    [...firstItemByType.values()].map((item) => {
      const asset = resolveGeometryAsset(item.visualKey);
      return `${asset.shape}-${asset.fill}-${asset.studsX}x${asset.studsY}-${asset.heightScale}`;
    }),
  );

  expect(firstItemByType.size).toBe(20);
  expect(visualKeys.size).toBe(20);
  expect(signatures.size).toBe(20);
});

test('积木池覆盖参考图里的特殊件', () => {
  const shapes = new Set(
    startLocalMatch3DRun(21).items.map(
      (item) => resolveGeometryAsset(item.visualKey).shape,
    ),
  );

  expect(shapes).toContain('brick');
  expect(shapes).toContain('tile');
  expect(shapes).toContain('slope');
  expect(shapes).toContain('cylinder');
  expect(shapes).toContain('ring');
  expect(shapes).toContain('arch');
  expect(shapes).toContain('cone');
});

test('3D 特殊积木件使用可辨认挤出轮廓而不是基础代理体', async () => {
  const three = await import('three');

  for (const shape of MATCH3D_EXTRUDED_READABLE_SHAPES) {
    const geometry = createMatch3DThreeGeometry(three, shape, 1);

    expect(geometry.type).toBe('ExtrudeGeometry');
  }
});

test('进阶档位保持 15 种视觉模型并按三消组复用', () => {
  const run = startLocalMatch3DRun(16);
  const countByVisualKey = new Map<string, number>();
  const typeByVisualKey = new Map<string, Set<string>>();

  for (const item of run.items) {
    countByVisualKey.set(
      item.visualKey,
      (countByVisualKey.get(item.visualKey) ?? 0) + 1,
    );
    typeByVisualKey.set(
      item.visualKey,
      typeByVisualKey.get(item.visualKey) ?? new Set(),
    );
    typeByVisualKey.get(item.visualKey)!.add(item.itemTypeId);
  }

  expect(countByVisualKey.size).toBe(15);
  expect(
    [...countByVisualKey.values()].sort((left, right) => left - right),
  ).toEqual([...Array(14).fill(3), 6]);
  expect(
    [...typeByVisualKey.values()].every(
      (itemTypeIds) => itemTypeIds.size === 1,
    ),
  ).toBe(true);
});

test('随机抽取不会刷新重复物品', () => {
  for (const clearCount of [1, 8, 12, 16, 21]) {
    const run = startLocalMatch3DRun(clearCount);
    const visualKeys = new Set(run.items.map((item) => item.visualKey));

    expect(visualKeys.size).toBe(resolveLocalMatch3DItemTypeCount(clearCount));
  }
});

test('硬核档位按五档体积比例生成尺寸', () => {
  const run = startLocalMatch3DRun(21);
  const radiusByVisualKey = new Map<string, number>();
  for (const item of run.items) {
    radiusByVisualKey.set(item.visualKey, item.radius);
  }

  const baseRadius = [...radiusByVisualKey.values()].find(
    (radius) => Math.abs(radius / 0.072 - 1) < 0.01,
  );
  expect(baseRadius).toBeTruthy();

  const tierCounts = new Map<string, number>();
  for (const radius of radiusByVisualKey.values()) {
    const relativeVolume = Math.pow(radius / baseRadius!, 3);
    const tier =
      relativeVolume >= 1.6
        ? 'XL'
        : relativeVolume >= 1.25
          ? 'L'
          : relativeVolume >= 0.65 && relativeVolume <= 0.85
            ? 'XS'
            : relativeVolume <= 0.5
              ? 'S'
              : 'M';
    tierCounts.set(tier, (tierCounts.get(tier) ?? 0) + 1);
  }

  expect(tierCounts.get('XL')).toBe(4);
  expect(tierCounts.get('L')).toBe(6);
  expect(tierCounts.get('M')).toBe(6);
  expect(tierCounts.get('XS')).toBe(3);
  expect(tierCounts.get('S')).toBe(1);
});

test('同一视觉模型在复用时保持唯一尺寸', () => {
  const run = startLocalMatch3DRun(21);
  const radiiByVisualKey = new Map<string, Set<number>>();

  for (const item of run.items) {
    const radii = radiiByVisualKey.get(item.visualKey) ?? new Set<number>();
    radii.add(Math.round(item.radius * 10_000));
    radiiByVisualKey.set(item.visualKey, radii);
  }

  expect(radiiByVisualKey.size).toBe(20);
  expect(
    [...radiiByVisualKey.values()].every((radii) => radii.size === 1),
  ).toBe(true);
});

test('托盘 3D 预览保留场内模型的相对尺寸比例', async () => {
  const three = await import('three');
  const run = startLocalMatch3DRun(21);
  const firstItemByType = [
    ...new Map(run.items.map((item) => [item.itemTypeId, item])).values(),
  ];
  const referenceDimension = resolveMatch3DTrayPreviewReferenceDimension(
    three,
    firstItemByType,
  );
  const previewRatios = new Set(
    firstItemByType.map((item) =>
      Math.round(
        (measureMatch3DItemPreviewDimension(three, item) / referenceDimension) *
          1_000,
      ),
    ),
  );

  expect(previewRatios.size).toBeGreaterThan(1);
});

test('托盘 3D 预览放大模型并展示俯视 3/4 体积感', () => {
  expect(MATCH3D_TRAY_MODEL_TARGET_SIZE).toBeGreaterThanOrEqual(0.85);
  expect(MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE).toBeGreaterThanOrEqual(0.9);

  const brickRotation = resolveMatch3DTrayPreviewRotation('block-red-2x4');
  const tileRotation = resolveMatch3DTrayPreviewRotation(
    'block-lavender-tile-2x2',
  );
  const slopeRotation = resolveMatch3DTrayPreviewRotation(
    'block-purple-slope-1x2',
  );

  expect(brickRotation.x).toBeLessThan(-0.28);
  expect(brickRotation.z).toBeGreaterThan(0.2);
  expect(brickRotation.y).toBeGreaterThan(0.6);
  expect(tileRotation.x).toBeLessThan(-0.25);
  expect(tileRotation.z).toBeGreaterThan(0.2);
  expect(slopeRotation.x).toBeLessThan(-0.3);
  expect(slopeRotation.z).toBeGreaterThan(0.22);
});

test('托盘 3D 预览为小模型保留最低可读显示尺寸', () => {
  const smallDimension = 0.4;
  const referenceDimension = 1;
  const scale = resolveMatch3DTrayPreviewScale(
    smallDimension,
    referenceDimension,
  );

  expect(scale * smallDimension).toBeGreaterThanOrEqual(
    MATCH3D_TRAY_MODEL_TARGET_SIZE * MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
  );
  expect(scale).toBeGreaterThan(
    MATCH3D_TRAY_MODEL_TARGET_SIZE / referenceDimension,
  );
});

test('积木 3D 资源可以为本局类型创建几何体', async () => {
  const three = await import('three');
  const run = startLocalMatch3DRun(15);
  const firstItemByType = new Map(
    run.items.map((item) => [item.itemTypeId, item]),
  );

  expect(firstItemByType.size).toBe(15);
  for (const item of firstItemByType.values()) {
    const shape = resolveGeometryAsset(item.visualKey).shape;
    const geometry = createMatch3DThreeGeometry(three, shape, 1);

    expect(geometry).toBeTruthy();
  }
});

test('3D 物体碰撞体按同款视觉尺寸生成', async () => {
  const cannon = await import('cannon-es');
  const longBrick = resolveGeometryAsset('block-black-1x8');
  const tile = resolveGeometryAsset('block-lavender-tile-2x2');
  const cylinder = resolveGeometryAsset('block-green-cylinder');
  const radius = 1;

  const longBrickBounds = resolveMatch3DColliderBounds(longBrick, radius);
  const longBrickShape = createMatch3DCannonShape(cannon, longBrick, radius);
  expect(longBrickShape.type).toBe(cannon.Shape.types.BOX);
  expect(
    (longBrickShape as import('cannon-es').Box).halfExtents.x * 2,
  ).toBeCloseTo(longBrickBounds.width);
  expect(
    (longBrickShape as import('cannon-es').Box).halfExtents.z * 2,
  ).toBeCloseTo(longBrickBounds.depth);

  const tileBounds = resolveMatch3DColliderBounds(tile, radius);
  const tileShape = createMatch3DCannonShape(cannon, tile, radius);
  expect((tileShape as import('cannon-es').Box).halfExtents.y * 2).toBeCloseTo(
    tileBounds.height,
  );

  const cylinderBounds = resolveMatch3DColliderBounds(cylinder, radius);
  const cylinderShape = createMatch3DCannonShape(cannon, cylinder, radius);
  expect(cylinderShape.type).toBe(cannon.Shape.types.CYLINDER);
  expect((cylinderShape as import('cannon-es').Cylinder).height).toBeCloseTo(
    cylinderBounds.height,
  );
});

test('中心场地 3D 纵深随物体总量增加并随消除进度回补', () => {
  const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30);
  const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300);
  const earlyBottomY = resolveMatch3DStackTargetY(300, 300, 0);
  const lateBottomY = resolveMatch3DStackTargetY(300, 60, 0);

  expect(largeDepthPlan.initialDepth).toBeGreaterThan(
    smallDepthPlan.initialDepth,
  );
  expect(largeDepthPlan.layerCapacity).toBeLessThan(
    smallDepthPlan.layerCapacity,
  );
  expect(largeDepthPlan.layerCount).toBeGreaterThan(smallDepthPlan.layerCount);
  expect(largeDepthPlan.surfaceY).toBeGreaterThan(largeDepthPlan.baseY);
  expect(lateBottomY).toBeGreaterThan(earlyBottomY);
  expect(lateBottomY).toBeLessThanOrEqual(largeDepthPlan.surfaceY);
});

test('高数量 3D 局面使用更稳定的物理参数', () => {
  const smallPlan = resolveMatch3DPhysicsStabilityPlan(30);
  const largePlan = resolveMatch3DPhysicsStabilityPlan(300);

  expect(largePlan.contactFriction).toBeGreaterThan(smallPlan.contactFriction);
  expect(largePlan.contactRestitution).toBeLessThan(
    smallPlan.contactRestitution,
  );
  expect(largePlan.linearDamping).toBeGreaterThan(smallPlan.linearDamping);
  expect(largePlan.angularDamping).toBeGreaterThan(smallPlan.angularDamping);
  expect(largePlan.solverIterations).toBeGreaterThan(
    smallPlan.solverIterations,
  );
  expect(largePlan.maxHorizontalSpeed).toBeLessThan(
    smallPlan.maxHorizontalSpeed,
  );
});

test('3D 真实边界半径比视觉半径更保守，避免长条贴边穿出锅壁', () => {
  const longBrick = resolveGeometryAsset('block-black-1x8');
  const radius = 1;
  const boundaryRadius = resolveMatch3DBoundaryRadius(longBrick, radius);
  const visualRadius = Math.hypot(
    resolveMatch3DColliderBounds(longBrick, radius).width / 2,
    resolveMatch3DColliderBounds(longBrick, radius).depth / 2,
  );

  expect(boundaryRadius).toBeCloseTo(visualRadius);
  expect(boundaryRadius).toBeGreaterThan(2.4);
});

test('100 次局面的新物体会按层级延迟生成并逐层回落', () => {
  const fastTimingPlan = resolveMatch3DSpawnTimingPlan(29);
  const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30);
  const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300);
  const smallTimingPlan = resolveMatch3DSpawnTimingPlan(30);
  const largeTimingPlan = resolveMatch3DSpawnTimingPlan(300);
  const bottomDelay = resolveMatch3DSpawnDelay(0, largeDepthPlan.layerCapacity);
  const middleDelay = resolveMatch3DSpawnDelay(
    30,
    largeDepthPlan.layerCapacity,
  );
  const topDelay = resolveMatch3DSpawnDelay(120, largeDepthPlan.layerCapacity);
  const dynamicCapacityDelay = resolveMatch3DSpawnDelay(
    120,
    largeDepthPlan.layerCapacity,
  );
  const defaultCapacityDelay = resolveMatch3DSpawnDelay(
    120,
    smallDepthPlan.layerCapacity,
  );

  expect(bottomDelay).toBe(0);
  expect(middleDelay).toBeGreaterThan(bottomDelay);
  expect(topDelay).toBeGreaterThan(middleDelay);
  expect(dynamicCapacityDelay).toBeGreaterThan(defaultCapacityDelay);
  expect(smallTimingPlan.frameSpawnLimit).toBeLessThan(
    fastTimingPlan.frameSpawnLimit,
  );
  expect(smallTimingPlan.burstSize).toBeLessThan(fastTimingPlan.burstSize);
  expect(smallTimingPlan.layerDelayMs).toBeGreaterThan(
    fastTimingPlan.layerDelayMs,
  );
  expect(
    resolveMatch3DSpawnDelay(29, smallDepthPlan.layerCapacity, smallTimingPlan),
  ).toBeGreaterThan(450);
  expect(largeTimingPlan.initialDelayMs).toBeGreaterThan(
    smallTimingPlan.initialDelayMs,
  );
  expect(largeTimingPlan.frameSpawnLimit).toBeLessThan(
    smallTimingPlan.frameSpawnLimit,
  );
  expect(largeTimingPlan.burstSize).toBeLessThanOrEqual(6);
  expect(largeTimingPlan.layerDelayMs).toBeGreaterThanOrEqual(
    smallTimingPlan.layerDelayMs,
  );
  expect(
    resolveMatch3DSpawnDelay(
      299,
      largeDepthPlan.layerCapacity,
      largeTimingPlan,
    ),
  ).toBeGreaterThan(5000);
});

test('3D 新物体生成高度会避让同位置已有堆叠', () => {
  const plannedSpawnY = 2;
  const raisedSpawnY = resolveMatch3DSpawnY(
    plannedSpawnY,
    0.8,
    0.7,
    { x: 0.1, z: 0.1 },
    [
      {
        boundaryRadius: 0.7,
        colliderHeight: 0.9,
        x: 0.18,
        y: 2.4,
        z: 0.15,
      },
    ],
  );
  const unchangedSpawnY = resolveMatch3DSpawnY(
    plannedSpawnY,
    0.8,
    0.7,
    { x: 0.1, z: 0.1 },
    [
      {
        boundaryRadius: 0.7,
        colliderHeight: 0.9,
        x: 3,
        y: 4,
        z: 3,
      },
    ],
  );

  expect(raisedSpawnY).toBeGreaterThan(plannedSpawnY);
  expect(unchangedSpawnY).toBe(plannedSpawnY);
});

test('3D 新物体生成动画只缩放可见模型并最终回到完整尺寸', () => {
  const startScale = resolveMatch3DSpawnVisualScale(0);
  const middleScale = resolveMatch3DSpawnVisualScale(0.5);
  const endScale = resolveMatch3DSpawnVisualScale(1);

  expect(startScale).toBeGreaterThan(0);
  expect(startScale).toBeLessThan(0.25);
  expect(middleScale).toBeGreaterThan(startScale);
  expect(middleScale).toBeLessThan(endScale);
  expect(endScale).toBe(1);
});

test('积木视觉键不会被统一兜底成红色苹字', () => {
  const run = startLocalMatch3DRun(2);
  run.items = run.items.slice(0, 2).map((item, index) => ({
    ...item,
    itemInstanceId: `block-${index}`,
    itemTypeId: `block-type-${index}`,
    visualKey: index === 0 ? 'block-red-2x4' : 'block-blue-1x2',
    x: 0.42 + index * 0.16,
    y: 0.5,
    layer: index,
    clickable: true,
  }));
  renderRuntime(run);

  expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
  expect(screen.getByTestId('match3d-visual-block-blue-1x2')).toBeTruthy();
  expect(screen.queryAllByText('苹')).toHaveLength(0);
});

test('积木视觉键渲染为无文字纯色图标', () => {
  const run = startLocalMatch3DRun(3);
  run.items = run.items.slice(0, 3).map((item, index) => ({
    ...item,
    itemInstanceId: `block-icon-${index}`,
    itemTypeId: `block-icon-type-${index}`,
    visualKey:
      index === 0
        ? 'block-red-2x4'
        : index === 1
          ? 'block-clear-ring'
          : 'block-mint-arch',
    x: 0.35 + index * 0.15,
    y: 0.5,
    radius: index === 0 ? 0.12 : index === 1 ? 0.09 : 0.07,
    layer: index,
    clickable: true,
  }));
  renderRuntime(run);

  expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
  expect(
    screen
      .getByTestId('match3d-visual-block-clear-ring')
      .getAttribute('data-shape'),
  ).toBe('ring');
  expect(
    screen
      .getByTestId('match3d-visual-block-mint-arch')
      .getAttribute('data-shape'),
  ).toBe('arch');
  expect(screen.queryByText('苹果')).toBeNull();
  expect(screen.queryByText('苹')).toBeNull();
});

test('运行态支持长条、斜坡和圆柱等差异化积木造型', () => {
  const run = startLocalMatch3DRun(3);
  run.items = run.items.slice(0, 3).map((item, index) => ({
    ...item,
    itemInstanceId: `block-geometry-${index}`,
    itemTypeId: `block-geometry-type-${index}`,
    visualKey:
      index === 0
        ? 'block-black-1x8'
        : index === 1
          ? 'block-purple-slope-1x2'
          : 'block-green-cylinder',
    x: 0.35 + index * 0.15,
    y: 0.5,
    layer: index,
    clickable: true,
  }));
  renderRuntime(run);

  expect(
    screen
      .getByTestId('match3d-visual-block-black-1x8')
      .getAttribute('data-shape'),
  ).toBe('brick');
  expect(
    screen
      .getByTestId('match3d-visual-block-purple-slope-1x2')
      .getAttribute('data-shape'),
  ).toBe('slope');
  expect(
    screen
      .getByTestId('match3d-visual-block-green-cylinder')
      .getAttribute('data-shape'),
  ).toBe('cylinder');
});

test('异常旧坐标只做显示层收束，不让物品贴出圆形空间', () => {
  const run = startLocalMatch3DRun(1);
  const item = run.items[0]!;
  run.items = [
    {
      ...item,
      itemInstanceId: 'legacy-outside',
      visualKey: 'block-red-2x4',
      x: -0.4,
      y: 0.5,
      radius: 0.1,
      clickable: true,
    },
  ];
  renderRuntime(run);

  const token = screen.getByTestId(
    'match3d-item-legacy-outside',
  ) as HTMLElement;
  expect(parseFloat(token.style.left)).toBeGreaterThanOrEqual(0);
  expect(parseFloat(token.style.left)).toBeLessThanOrEqual(100);
});

test('本地运行态物品围绕容器口中心生成，不贴边挤在局部', () => {
  const run = startLocalMatch3DRun(21);
  const boardItems = run.items.filter((item) => item.state === 'InBoard');
  const meanX =
    boardItems.reduce((sum, item) => sum + item.x, 0) / boardItems.length;
  const meanY =
    boardItems.reduce((sum, item) => sum + item.y, 0) / boardItems.length;
  const distances = boardItems.map((item) =>
    Math.hypot(item.x - 0.5, item.y - 0.5),
  );
  const farItems = distances.filter((distance) => distance > 0.26);
  const quadrants = new Set(
    boardItems.map(
      (item) => `${item.x >= 0.5 ? 'r' : 'l'}-${item.y >= 0.5 ? 'b' : 't'}`,
    ),
  );

  expect(Math.abs(meanX - 0.5)).toBeLessThan(0.035);
  expect(Math.abs(meanY - 0.5)).toBeLessThan(0.035);
  expect(Math.max(...distances)).toBeLessThan(0.4);
  expect(farItems.length).toBeGreaterThan(0);
  expect(quadrants.size).toBe(4);
});
