/* @vitest-environment jsdom */

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

import type {
  AuthUser,
  PublicUserSummary,
} from '../../../packages/shared/src/contracts/auth';
import type {
  ConfirmWechatProfileRechargeOrderResponse,
  CreateProfileRechargeOrderResponse,
  ProfileReferralInviteCenterResponse,
  ProfileTaskCenterResponse,
} from '../../../packages/shared/src/contracts/runtime';
import { AuthUiContext } from '../auth/AuthUiContext';
import { ICP_RECORD_NUMBER, ICP_RECORD_URL } from '../common/legalDocuments';
import {
  RpgEntryHomeView,
  type RpgEntryHomeViewProps,
} from './RpgEntryHomeView';
import {
  EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
  EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
  type PlatformEdutainmentGalleryCard,
  type PlatformPublicGalleryCard,
  type PlatformPuzzleGalleryCard,
} from './rpgEntryWorldPresentation';

const {
  mockBuildReferralCenter,
  mockBuildTaskCenter,
  mockClaimRpgProfileTaskReward,
  mockConfirmWechatRpgProfileRechargeOrder,
  mockCreateRpgProfileRechargeOrder,
  mockGetRpgProfileReferralInviteCenter,
  mockGetRpgProfileRechargeCenter,
  mockGetRpgProfileTasks,
  mockGetRpgProfileWalletLedger,
  mockRedeemRpgProfileReferralInviteCode,
} = vi.hoisted(() => {
  const buildReferralCenter = (
    overrides: Partial<ProfileReferralInviteCenterResponse> = {},
  ): ProfileReferralInviteCenterResponse => ({
    inviteCode: 'SY12345678',
    inviteLinkPath: '/?inviteCode=SY12345678',
    invitedCount: 1,
    rewardedInviteCount: 1,
    todayInviterRewardCount: 1,
    todayInviterRewardRemaining: 9,
    rewardPoints: 30,
    invitedUsers: [
      {
        userId: 'user-2',
        displayName: '被邀请玩家',
        avatarUrl: null,
        boundAt: '2026-05-01T08:00:00Z',
      },
    ],
    hasRedeemedCode: false,
    boundInviterUserId: null,
    boundAt: null,
    updatedAt: '2026-05-01T08:00:00Z',
    ...overrides,
  });
  const buildTaskCenter = (
    overrides: Partial<ProfileTaskCenterResponse> = {},
  ): ProfileTaskCenterResponse => ({
    dayKey: 20260503,
    walletBalance: 0,
    tasks: [
      {
        taskId: 'daily_login',
        title: '每日登录',
        description: '',
        eventKey: 'profile.login.daily',
        cycle: 'daily',
        threshold: 1,
        progressCount: 1,
        rewardPoints: 10,
        status: 'claimable',
        dayKey: 20260503,
        claimedAt: null,
        updatedAt: '2026-05-03T08:00:00Z',
      },
    ],
    updatedAt: '2026-05-03T08:00:00Z',
    ...overrides,
  });
  const buildClaimedTaskCenter = () =>
    buildTaskCenter({
      walletBalance: 10,
      tasks: [
        {
          taskId: 'daily_login',
          title: '每日登录',
          description: '',
          eventKey: 'profile.login.daily',
          cycle: 'daily',
          threshold: 1,
          progressCount: 1,
          rewardPoints: 10,
          status: 'claimed',
          dayKey: 20260503,
          claimedAt: '2026-05-03T08:01:00Z',
          updatedAt: '2026-05-03T08:01:00Z',
        },
      ],
      updatedAt: '2026-05-03T08:01:00Z',
    });

  return {
    mockBuildReferralCenter: buildReferralCenter,
    mockBuildTaskCenter: buildTaskCenter,
    mockGetRpgProfileReferralInviteCenter: vi.fn(async () =>
      buildReferralCenter(),
    ),
    mockGetRpgProfileTasks: vi.fn(async () => buildTaskCenter()),
    mockClaimRpgProfileTaskReward: vi.fn(async () => ({
      taskId: 'daily_login',
      dayKey: 20260503,
      rewardPoints: 10,
      walletBalance: 10,
      ledgerEntry: {
        id: 'ledger-daily-login',
        amountDelta: 10,
        balanceAfter: 10,
        sourceType: 'daily_task_reward',
        createdAt: '2026-05-03T08:01:00Z',
      },
      center: buildClaimedTaskCenter(),
    })),
    mockGetRpgProfileRechargeCenter: vi.fn(async () => ({
      walletBalance: 0,
      membership: {
        status: 'normal',
        tier: 'normal',
        startedAt: null,
        expiresAt: null,
        updatedAt: null,
      },
      pointProducts: [
        {
          productId: 'points_60',
          title: '60泥点',
          priceCents: 600,
          kind: 'points',
          pointsAmount: 60,
          bonusPoints: 60,
          durationDays: 0,
          badgeLabel: '首充双倍',
          description: '首充送60泥点',
          tier: 'normal',
        },
      ],
      membershipProducts: [
        {
          productId: 'member_month',
          title: '月卡',
          priceCents: 2800,
          kind: 'membership',
          pointsAmount: 0,
          bonusPoints: 0,
          durationDays: 30,
          badgeLabel: '',
          description: '30天会员',
          tier: 'month',
        },
      ],
      benefits: [
        {
          benefitName: '免泥点回合数',
          normalValue: '30',
          monthValue: '100',
          seasonValue: '100',
          yearValue: '100',
        },
      ],
      latestOrder: null,
      hasPointsRecharged: false,
    })),
    mockCreateRpgProfileRechargeOrder: vi.fn(
      async (): Promise<CreateProfileRechargeOrderResponse> => ({
        order: {
          orderId: 'order-1',
          productId: 'points_60',
          productTitle: '60泥点',
          kind: 'points',
          amountCents: 600,
          status: 'paid',
          paymentChannel: 'mock',
          paidAt: '2026-04-25T10:00:00Z',
          providerTransactionId: null,
          createdAt: '2026-04-25T10:00:00Z',
          pointsDelta: 120,
          membershipExpiresAt: null,
        },
        center: {
          walletBalance: 120,
          membership: {
            status: 'normal',
            tier: 'normal',
            startedAt: null,
            expiresAt: null,
            updatedAt: null,
          },
          pointProducts: [],
          membershipProducts: [],
          benefits: [],
          latestOrder: null,
          hasPointsRecharged: true,
        },
      }),
    ),
    mockConfirmWechatRpgProfileRechargeOrder: vi.fn(
      async (): Promise<ConfirmWechatProfileRechargeOrderResponse> => ({
        order: {
          orderId: 'order-wechat-1',
          productId: 'points_60',
          productTitle: '60泥点',
          kind: 'points',
          amountCents: 600,
          status: 'paid',
          paymentChannel: 'wechat_mp',
          paidAt: '2026-04-25T10:01:00Z',
          providerTransactionId: 'wx-transaction-1',
          createdAt: '2026-04-25T10:00:00Z',
          pointsDelta: 120,
          membershipExpiresAt: null,
        },
        center: {
          walletBalance: 120,
          membership: {
            status: 'normal',
            tier: 'normal',
            startedAt: null,
            expiresAt: null,
            updatedAt: null,
          },
          pointProducts: [
            {
              productId: 'points_60',
              title: '60泥点',
              priceCents: 600,
              kind: 'points',
              pointsAmount: 60,
              bonusPoints: 0,
              durationDays: 0,
              badgeLabel: '',
              description: '60泥点',
              tier: 'normal',
            },
          ],
          membershipProducts: [],
          benefits: [],
          latestOrder: {
            orderId: 'order-wechat-1',
            productId: 'points_60',
            productTitle: '60泥点',
            kind: 'points',
            amountCents: 600,
            status: 'paid',
            paymentChannel: 'wechat_mp',
            providerTransactionId: 'wx-transaction-1',
            createdAt: '2026-04-25T10:00:00Z',
            paidAt: '2026-04-25T10:01:00Z',
            pointsDelta: 120,
            membershipExpiresAt: null,
          },
          hasPointsRecharged: true,
        },
      }),
    ),
    mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
      center: buildReferralCenter({
        invitedUsers: [],
        hasRedeemedCode: true,
        boundInviterUserId: 'user-2',
        boundAt: '2026-05-01T08:00:00Z',
      }),
      inviteeRewardGranted: true,
      inviterRewardGranted: true,
      inviteeBalanceAfter: 30,
      inviterBalanceAfter: 30,
    })),
    mockGetRpgProfileWalletLedger: vi.fn(async () => ({
      entries: [
        {
          id: 'ledger-1',
          amountDelta: -1,
          balanceAfter: 29,
          sourceType: 'asset_operation_consume',
          createdAt: '2026-04-28T10:00:00Z',
        },
        {
          id: 'ledger-2',
          amountDelta: 30,
          balanceAfter: 30,
          sourceType: 'invite_invitee_reward',
          createdAt: '2026-04-28T09:00:00Z',
        },
      ],
    })),
  };
});

const {
  mockGetPublicAuthUserByCode,
  mockGetPublicAuthUserById,
  mockUpdateAuthProfile,
} = vi.hoisted(() => ({
  mockGetPublicAuthUserByCode: vi.fn(
    async (code: string): Promise<PublicUserSummary> => ({
      id: `id-${code}`,
      publicUserCode: code,
      displayName: '公开作者',
      avatarUrl: null,
    }),
  ),
  mockGetPublicAuthUserById: vi.fn(
    async (userId: string): Promise<PublicUserSummary> => ({
      id: userId,
      publicUserCode: `code-${userId}`,
      displayName: '公开作者',
      avatarUrl: null,
    }),
  ),
  mockUpdateAuthProfile: vi.fn(),
}));

vi.mock('../../services/authService', () => ({
  getPublicAuthUserByCode: mockGetPublicAuthUserByCode,
  getPublicAuthUserById: mockGetPublicAuthUserById,
  updateAuthProfile: mockUpdateAuthProfile,
}));

mockUpdateAuthProfile.mockResolvedValue({
  id: 'user-1',
  publicUserCode: '100001',
  username: 'tester',
  displayName: '测试玩家',
  avatarUrl: null,
  phoneNumberMasked: null,
  loginMethod: 'password',
  bindingStatus: 'active',
  wechatBound: false,
  createdAt: new Date().toISOString(),
});

vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
  getRpgProfileReferralInviteCenter: mockGetRpgProfileReferralInviteCenter,
  getRpgProfileTasks: mockGetRpgProfileTasks,
  getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
  claimRpgProfileTaskReward: mockClaimRpgProfileTaskReward,
  redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
  getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter,
  createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder,
  confirmWechatRpgProfileRechargeOrder:
    mockConfirmWechatRpgProfileRechargeOrder,
}));

vi.mock('../ResolvedAssetImage', () => ({
  ResolvedAssetImage: ({
    src,
    alt,
    className,
    ...rest
  }: {
    src?: string | null;
    alt?: string;
    className?: string;
  }) =>
    src ? (
      <img src={src} alt={alt ?? ''} className={className} {...rest} />
    ) : null,
}));

const originalMatchMedia = window.matchMedia;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;

function dispatchPointerEvent(
  target: HTMLElement,
  type: string,
  options: { pointerId: number; clientY: number },
) {
  const event = new Event(type, { bubbles: true, cancelable: true });
  Object.assign(event, options);
  target.dispatchEvent(event);
}

function stubImage(width = 800, height = 600) {
  class MockImage {
    onload: null | (() => void) = null;
    onerror: null | (() => void) = null;
    naturalWidth = width;
    naturalHeight = height;
    width = width;
    height = height;

    set src(_value: string) {
      this.onload?.();
    }
  }

  vi.stubGlobal('Image', MockImage as unknown as typeof Image);
}

function stubFileReader(dataUrl: string) {
  class MockFileReader {
    result: string | null = null;
    onload: null | (() => void) = null;
    onerror: null | (() => void) = null;

    readAsDataURL() {
      this.result = dataUrl;
      this.onload?.();
    }
  }

  vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
}

const puzzlePublicEntry = {
  sourceType: 'puzzle',
  workId: 'puzzle-work-public-1',
  profileId: 'puzzle-profile-public-1',
  publicWorkCode: 'PZ-EPUBLIC1',
  ownerUserId: 'user-2',
  authorDisplayName: '拼图玩家',
  worldName: '奇幻拼图',
  subtitle: '拼图关卡',
  summaryText: '一张用于公开分享的拼图作品。',
  coverImageSrc: null,
  themeTags: ['奇幻'],
  playCount: 20,
  remixCount: 5,
  likeCount: 12,
  visibility: 'published',
  publishedAt: '1777110165.990127Z',
  updatedAt: '2026-04-25T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;

const remixRankEntry = {
  ...puzzlePublicEntry,
  workId: 'puzzle-work-remix-rank',
  profileId: 'puzzle-profile-remix-rank',
  publicWorkCode: 'PZ-REMIX1',
  worldName: '改造高分拼图',
  playCount: 2,
  remixCount: 18,
  likeCount: 1,
  recentPlayCount7d: 0,
  publishedAt: '2026-04-25T11:00:00.000Z',
  updatedAt: '2026-04-25T11:00:00.000Z',
} satisfies PlatformPublicGalleryCard;

function buildCarouselPuzzleEntry(
  id: string,
  worldName: string,
  coverPrefix: string,
) {
  return {
    ...puzzlePublicEntry,
    workId: `puzzle-work-${id}`,
    profileId: `puzzle-profile-${id}`,
    publicWorkCode: `PZ-${id.toUpperCase()}`,
    worldName,
    coverImageSrc: `${coverPrefix}-fallback.png`,
    coverSlides: [
      {
        id: `${id}-cover-1`,
        imageSrc: `${coverPrefix}-1.png`,
        label: `${worldName} 1`,
      },
      {
        id: `${id}-cover-2`,
        imageSrc: `${coverPrefix}-2.png`,
        label: `${worldName} 2`,
      },
    ],
  } satisfies PlatformPublicGalleryCard;
}

const hotRankEntry = {
  ...puzzlePublicEntry,
  workId: 'puzzle-work-hot-rank',
  profileId: 'puzzle-profile-hot-rank',
  publicWorkCode: 'PZ-HOT001',
  worldName: '热门高分拼图',
  themeTags: ['奇幻', '机关'],
  playCount: 40,
  remixCount: 1,
  likeCount: 4,
  recentPlayCount7d: 0,
  publishedAt: '2026-04-24T10:00:00.000Z',
  updatedAt: '2026-04-24T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;

const newRankEntry = {
  ...puzzlePublicEntry,
  workId: 'puzzle-work-new-rank',
  profileId: 'puzzle-profile-new-rank',
  publicWorkCode: 'PZ-NEW001',
  worldName: '新品增长拼图',
  playCount: 1,
  remixCount: 0,
  likeCount: 0,
  recentPlayCount7d: 9,
  publishedAt: '2026-04-20T10:00:00.000Z',
  updatedAt: '2026-04-20T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;

const longTextRankEntry = {
  ...puzzlePublicEntry,
  workId: 'puzzle-work-long-text-rank',
  profileId: 'puzzle-profile-long-text-rank',
  publicWorkCode: 'PZ-LONG01',
  worldName: '关键词逍遥游拼图关卡',
  themeTags: ['逍遥游拼图', '古风机关'],
  playCount: 88,
  remixCount: 0,
  likeCount: 0,
  recentPlayCount7d: 0,
  publishedAt: '2026-04-29T10:00:00.000Z',
  updatedAt: '2026-04-29T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;

function buildTaggedPuzzleEntry(
  id: string,
  worldName: string,
  themeTags: string[],
  overrides: Partial<PlatformPuzzleGalleryCard> = {},
) {
  return {
    ...puzzlePublicEntry,
    workId: `puzzle-work-${id}`,
    profileId: `puzzle-profile-${id}`,
    publicWorkCode: `PZ-${id.toUpperCase()}`,
    worldName,
    themeTags,
    ...overrides,
  } satisfies PlatformPuzzleGalleryCard;
}

function buildBabyObjectMatchEntry(
  id: string,
  worldName: string,
  themeTags: string[] = ['寓教于乐'],
  overrides: Partial<PlatformEdutainmentGalleryCard> = {},
) {
  return {
    sourceType: 'edutainment',
    templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
    templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
    workId: `baby-object-match-work-${id}`,
    profileId: `baby-object-match-profile-${id}`,
    publicWorkCode: `EDU-${id.toUpperCase()}`,
    ownerUserId: 'user-edutainment',
    authorDisplayName: '动作 Demo 作者',
    worldName,
    subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
    summaryText: '将物品放入对应的篮子里。',
    coverImageSrc: null,
    themeTags,
    playCount: 8,
    remixCount: 0,
    likeCount: 4,
    recentPlayCount7d: 5,
    visibility: 'published',
    publishedAt: '2026-05-11T10:00:00.000Z',
    updatedAt: '2026-05-11T10:00:00.000Z',
    ...overrides,
  } satisfies PlatformEdutainmentGalleryCard;
}

function mockDesktopLayout() {
  Object.defineProperty(window, 'matchMedia', {
    configurable: true,
    writable: true,
    value: vi.fn().mockImplementation(() => ({
      matches: true,
      media: '(min-width: 1024px)',
      onchange: null,
      addEventListener: vi.fn(),
      removeEventListener: vi.fn(),
      addListener: vi.fn(),
      removeListener: vi.fn(),
      dispatchEvent: vi.fn(),
    })),
  });
}

function renderProfileView(
  onRechargeSuccess = vi.fn(),
  profileDashboardOverrides: Partial<
    NonNullable<RpgEntryHomeViewProps['profileDashboard']>
  > = {},
  userOverrides: Partial<AuthUser> = {},
) {
  return render(
    <AuthUiContext.Provider
      value={{
        user: {
          id: 'user-1',
          publicUserCode: '100001',
          username: 'tester',
          displayName: '测试玩家',
          avatarUrl: null,
          phoneNumberMasked: null,
          loginMethod: 'password',
          bindingStatus: 'active',
          wechatBound: false,
          createdAt: new Date().toISOString(),
          ...userOverrides,
        },
        canAccessProtectedData: true,
        openLoginModal: vi.fn(),
        requireAuth: (action) => action(),
        openSettingsModal: vi.fn(),
        openAccountModal: vi.fn(),
        setCurrentUser: vi.fn(),
        logout: vi.fn(async () => undefined),
        musicVolume: 0.42,
        setMusicVolume: vi.fn(),
        platformTheme: 'light',
        setPlatformTheme: vi.fn(),
        isHydratingSettings: false,
        isPersistingSettings: false,
        settingsError: null,
      }}
    >
      <RpgEntryHomeView
        activeTab="profile"
        onTabChange={vi.fn()}
        hasSavedGame={false}
        savedSnapshot={null}
        saveEntries={[]}
        saveError={null}
        featuredEntries={[]}
        latestEntries={[]}
        myEntries={[]}
        historyEntries={[]}
        profileDashboard={{
          walletBalance: 0,
          totalPlayTimeMs: 0,
          playedWorldCount: 0,
          updatedAt: null,
          ...profileDashboardOverrides,
        }}
        isLoadingPlatform={false}
        isLoadingDashboard={false}
        isResumingSaveWorldKey={null}
        platformError={null}
        dashboardError={null}
        onContinueGame={vi.fn()}
        onResumeSave={vi.fn()}
        onOpenCreateWorld={vi.fn()}
        onOpenCreateTypePicker={vi.fn()}
        onOpenGalleryDetail={vi.fn()}
        onOpenLibraryDetail={vi.fn()}
        onSearchPublicCode={vi.fn()}
        onRechargeSuccess={onRechargeSuccess}
      />
    </AuthUiContext.Provider>,
  );
}

async function openRechargeModal(user: ReturnType<typeof userEvent.setup>) {
  await user.click(screen.getByRole('button', { name: /充值\s*泥点\/会员/u }));
}

function renderLoggedOutHomeView(
  openLoginModal = vi.fn(),
  overrides: Partial<
    Pick<
      RpgEntryHomeViewProps,
      | 'featuredEntries'
      | 'latestEntries'
      | 'onOpenGalleryDetail'
      | 'onOpenRecommendGalleryDetail'
      | 'onSearchPublicCode'
      | 'recommendRuntimeContent'
      | 'activeRecommendEntryKey'
      | 'isStartingRecommendEntry'
      | 'recommendRuntimeError'
      | 'onSelectNextRecommendEntry'
      | 'onSelectPreviousRecommendEntry'
    >
  > = {},
  activeTab: RpgEntryHomeViewProps['activeTab'] = 'home',
) {
  return render(
    <AuthUiContext.Provider
      value={{
        user: null,
        canAccessProtectedData: false,
        openLoginModal,
        requireAuth: vi.fn(),
        openSettingsModal: vi.fn(),
        openAccountModal: vi.fn(),
        setCurrentUser: vi.fn(),
        logout: vi.fn(async () => undefined),
        musicVolume: 0.42,
        setMusicVolume: vi.fn(),
        platformTheme: 'light',
        setPlatformTheme: vi.fn(),
        isHydratingSettings: false,
        isPersistingSettings: false,
        settingsError: null,
      }}
    >
      <RpgEntryHomeView
        activeTab={activeTab}
        onTabChange={vi.fn()}
        hasSavedGame={false}
        savedSnapshot={null}
        saveEntries={[]}
        saveError={null}
        featuredEntries={overrides.featuredEntries ?? []}
        latestEntries={overrides.latestEntries ?? []}
        myEntries={[]}
        historyEntries={[]}
        profileDashboard={null}
        isLoadingPlatform={false}
        isLoadingDashboard={false}
        isResumingSaveWorldKey={null}
        platformError={null}
        dashboardError={null}
        onContinueGame={vi.fn()}
        onResumeSave={vi.fn()}
        onOpenCreateWorld={vi.fn()}
        onOpenCreateTypePicker={vi.fn()}
        onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
        onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail}
        recommendRuntimeContent={
          overrides.recommendRuntimeContent ?? (
            <div data-testid="recommend-runtime">运行内容</div>
          )
        }
        activeRecommendEntryKey={overrides.activeRecommendEntryKey}
        isStartingRecommendEntry={overrides.isStartingRecommendEntry}
        recommendRuntimeError={overrides.recommendRuntimeError}
        onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
        onSelectPreviousRecommendEntry={
          overrides.onSelectPreviousRecommendEntry
        }
        onOpenLibraryDetail={vi.fn()}
        onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
      />
    </AuthUiContext.Provider>,
  );
}

function renderLoggedInHomeView(
  overrides: Partial<
    Pick<
      RpgEntryHomeViewProps,
      'activeTab' | 'hasUnreadDraftUpdate' | 'draftTabContent'
    >
  > = {},
) {
  return render(
    <AuthUiContext.Provider
      value={{
        user: {
          id: 'user-1',
          publicUserCode: '100001',
          username: 'tester',
          displayName: '测试玩家',
          avatarUrl: null,
          phoneNumberMasked: null,
          loginMethod: 'password',
          bindingStatus: 'active',
          wechatBound: false,
          createdAt: new Date().toISOString(),
        },
        canAccessProtectedData: true,
        openLoginModal: vi.fn(),
        requireAuth: (action) => action(),
        openSettingsModal: vi.fn(),
        openAccountModal: vi.fn(),
        setCurrentUser: vi.fn(),
        logout: vi.fn(async () => undefined),
        musicVolume: 0.42,
        setMusicVolume: vi.fn(),
        platformTheme: 'light',
        setPlatformTheme: vi.fn(),
        isHydratingSettings: false,
        isPersistingSettings: false,
        settingsError: null,
      }}
    >
      <RpgEntryHomeView
        activeTab={overrides.activeTab ?? 'saves'}
        onTabChange={vi.fn()}
        hasSavedGame={false}
        savedSnapshot={null}
        saveEntries={[]}
        saveError={null}
        featuredEntries={[]}
        latestEntries={[]}
        myEntries={[]}
        historyEntries={[]}
        profileDashboard={null}
        isLoadingPlatform={false}
        isLoadingDashboard={false}
        isResumingSaveWorldKey={null}
        platformError={null}
        dashboardError={null}
        onContinueGame={vi.fn()}
        onResumeSave={vi.fn()}
        onOpenCreateWorld={vi.fn()}
        onOpenCreateTypePicker={vi.fn()}
        onOpenGalleryDetail={vi.fn()}
        onOpenLibraryDetail={vi.fn()}
        onSearchPublicCode={vi.fn()}
        hasUnreadDraftUpdate={overrides.hasUnreadDraftUpdate ?? false}
        draftTabContent={overrides.draftTabContent}
      />
    </AuthUiContext.Provider>,
  );
}

function renderStatefulLoggedOutHomeView(
  overrides: Partial<
    Pick<
      RpgEntryHomeViewProps,
      | 'featuredEntries'
      | 'latestEntries'
      | 'onOpenGalleryDetail'
      | 'onOpenRecommendGalleryDetail'
      | 'onSearchPublicCode'
      | 'recommendRuntimeContent'
      | 'activeRecommendEntryKey'
      | 'onSelectNextRecommendEntry'
      | 'onSelectPreviousRecommendEntry'
    >
  > = {},
) {
  const authSpies = {
    openLoginModal: vi.fn(),
  };

  function StatefulLoggedOutHomeView() {
    const [activeTab, setActiveTab] =
      useState<RpgEntryHomeViewProps['activeTab']>('category');

    return (
      <AuthUiContext.Provider
        value={{
          user: null,
          canAccessProtectedData: false,
          openLoginModal: authSpies.openLoginModal,
          requireAuth: vi.fn(),
          openSettingsModal: vi.fn(),
          openAccountModal: vi.fn(),
          setCurrentUser: vi.fn(),
          logout: vi.fn(async () => undefined),
          musicVolume: 0.42,
          setMusicVolume: vi.fn(),
          platformTheme: 'light',
          setPlatformTheme: vi.fn(),
          isHydratingSettings: false,
          isPersistingSettings: false,
          settingsError: null,
        }}
      >
        <RpgEntryHomeView
          activeTab={activeTab}
          onTabChange={setActiveTab}
          hasSavedGame={false}
          savedSnapshot={null}
          saveEntries={[]}
          saveError={null}
          featuredEntries={overrides.featuredEntries ?? []}
          latestEntries={overrides.latestEntries ?? []}
          myEntries={[]}
          historyEntries={[]}
          profileDashboard={null}
          isLoadingPlatform={false}
          isLoadingDashboard={false}
          isResumingSaveWorldKey={null}
          platformError={null}
          dashboardError={null}
          onContinueGame={vi.fn()}
          onResumeSave={vi.fn()}
          onOpenCreateWorld={vi.fn()}
          onOpenCreateTypePicker={vi.fn()}
          onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
          onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail}
          recommendRuntimeContent={
            overrides.recommendRuntimeContent ?? (
              <div data-testid="recommend-runtime" />
            )
          }
          activeRecommendEntryKey={overrides.activeRecommendEntryKey}
          onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
          onSelectPreviousRecommendEntry={
            overrides.onSelectPreviousRecommendEntry
          }
          onOpenLibraryDetail={vi.fn()}
          onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
        />
      </AuthUiContext.Provider>
    );
  }

  return {
    ...render(<StatefulLoggedOutHomeView />),
    openLoginModal: authSpies.openLoginModal,
  };
}

afterEach(() => {
  vi.useRealTimers();
  vi.clearAllMocks();
  vi.unstubAllEnvs();
  vi.unstubAllGlobals();
  window.wx = undefined;
  document
    .querySelectorAll(
      'script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]',
    )
    .forEach((script) => script.remove());
  mockGetRpgProfileReferralInviteCenter.mockResolvedValue(
    mockBuildReferralCenter(),
  );
  mockGetRpgProfileTasks.mockResolvedValue(mockBuildTaskCenter());
  mockClaimRpgProfileTaskReward.mockResolvedValue({
    taskId: 'daily_login',
    dayKey: 20260503,
    rewardPoints: 10,
    walletBalance: 10,
    ledgerEntry: {
      id: 'ledger-daily-login',
      amountDelta: 10,
      balanceAfter: 10,
      sourceType: 'daily_task_reward',
      createdAt: '2026-05-03T08:01:00Z',
    },
    center: mockBuildTaskCenter({
      walletBalance: 10,
      tasks: [
        {
          taskId: 'daily_login',
          title: '每日登录',
          description: '',
          eventKey: 'profile.login.daily',
          cycle: 'daily',
          threshold: 1,
          progressCount: 1,
          rewardPoints: 10,
          status: 'claimed',
          dayKey: 20260503,
          claimedAt: '2026-05-03T08:01:00Z',
          updatedAt: '2026-05-03T08:01:00Z',
        },
      ],
      updatedAt: '2026-05-03T08:01:00Z',
    }),
  });
  mockUpdateAuthProfile.mockResolvedValue({
    id: 'user-1',
    publicUserCode: '100001',
    username: 'tester',
    displayName: '测试玩家',
    avatarUrl: null,
    phoneNumberMasked: null,
    loginMethod: 'password',
    bindingStatus: 'active',
    wechatBound: false,
    createdAt: new Date().toISOString(),
  });
  Object.defineProperty(window, 'matchMedia', {
    configurable: true,
    writable: true,
    value: originalMatchMedia,
  });
  Object.defineProperty(window, 'requestAnimationFrame', {
    configurable: true,
    writable: true,
    value: originalRequestAnimationFrame,
  });
  Object.defineProperty(window, 'cancelAnimationFrame', {
    configurable: true,
    writable: true,
    value: originalCancelAnimationFrame,
  });
  Object.defineProperty(navigator, 'clipboard', {
    configurable: true,
    value: undefined,
  });
  window.history.replaceState(null, '', '/');
});

test('opens wallet ledger modal from narrative coin card', async () => {
  const user = userEvent.setup();

  renderProfileView();
  await user.click(screen.getByRole('button', { name: /泥点\s*0/u }));

  expect(await screen.findByText('泥点账单')).toBeTruthy();
  expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
  expect(screen.getByText('资产操作消耗')).toBeTruthy();
  expect(screen.getByText('-1')).toBeTruthy();
  expect(screen.getByText('填写邀请码奖励')).toBeTruthy();
  expect(screen.getByText('+30')).toBeTruthy();
});

test('profile recharge modal buys points through mock channel outside mini program', async () => {
  const user = userEvent.setup();
  const onRechargeSuccess = vi.fn();

  renderProfileView(onRechargeSuccess);
  await openRechargeModal(user);

  expect(await screen.findByText('账户充值')).toBeTruthy();
  expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1);
  await user.click(screen.getByRole('button', { name: /60泥点/u }));

  await waitFor(() => {
    expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
      'points_60',
      'mock',
    );
  });
  expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
  expect(screen.getByText('已到账，账户状态已刷新。')).toBeTruthy();
  expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});

test('profile recharge modal trusts per-product first bonus display after points recharge', async () => {
  const user = userEvent.setup();
  mockGetRpgProfileRechargeCenter.mockResolvedValueOnce({
    walletBalance: 60,
    membership: {
      status: 'normal',
      tier: 'normal',
      startedAt: null,
      expiresAt: null,
      updatedAt: null,
    },
    pointProducts: [
      {
        productId: 'points_60',
        title: '60泥点',
        priceCents: 600,
        kind: 'points',
        pointsAmount: 60,
        bonusPoints: 60,
        durationDays: 0,
        badgeLabel: '首充双倍',
        description: '首充送60泥点',
        tier: 'normal',
      },
    ],
    membershipProducts: [],
    benefits: [],
    latestOrder: null,
    hasPointsRecharged: true,
  });

  renderProfileView();
  await openRechargeModal(user);

  const rechargeDialog = await screen.findByText('账户充值');
  expect(rechargeDialog).toBeTruthy();
  expect(screen.getByRole('button', { name: /60泥点/u })).toBeTruthy();
  expect(screen.getByText('首充双倍')).toBeTruthy();
  expect(screen.getByText('60+60泥点')).toBeTruthy();
});

test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
  const user = userEvent.setup();
  const onRechargeSuccess = vi.fn();
  window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
  const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
    options.success?.();
  });
  window.wx = {
    miniProgram: {
      navigateTo,
    },
  };
  mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
    order: {
      orderId: 'order-wechat-1',
      productId: 'points_60',
      productTitle: '60泥点',
      kind: 'points',
      amountCents: 600,
      status: 'pending' as const,
      paymentChannel: 'wechat_mp',
      paidAt: null as string | null,
      providerTransactionId: null,
      createdAt: '2026-04-25T10:00:00Z',
      pointsDelta: 0,
      membershipExpiresAt: null,
    },
    center: {
      walletBalance: 0,
      membership: {
        status: 'normal',
        tier: 'normal',
        startedAt: null,
        expiresAt: null,
        updatedAt: null,
      },
      pointProducts: [],
      membershipProducts: [],
      benefits: [],
      latestOrder: null,
      hasPointsRecharged: false,
    },
    wechatMiniProgramPayParams: {
      timeStamp: '1777110165',
      nonceStr: 'nonce',
      package: 'prepay_id=wx-prepay',
      signType: 'RSA',
      paySign: 'signature',
    },
  });

  renderProfileView(onRechargeSuccess);
  await openRechargeModal(user);
  await user.click(await screen.findByRole('button', { name: /60泥点/u }));

  await waitFor(() => {
    expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
      'points_60',
      'wechat_mp',
    );
  });
  expect(navigateTo).toHaveBeenCalledWith({
    url: expect.stringContaining('/pages/wechat-pay/index?'),
    success: expect.any(Function),
    fail: expect.any(Function),
  });
  const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
  const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
    'requestId',
  );
  expect(requestId).toBeTruthy();
  act(() => {
    window.location.hash = `wx_pay_result=${requestId}:success`;
    window.dispatchEvent(new HashChangeEvent('hashchange'));
  });
  expect(navigateUrl).toContain('order-wechat-1');
  expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
  expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
  expect(screen.getByText('已到账，账户状态已刷新。')).toBeTruthy();
  expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
    'order-wechat-1',
  );
  expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});

test('profile recharge modal waits for paid confirmation before refreshing dashboard', async () => {
  const user = userEvent.setup();
  const onRechargeSuccess = vi.fn();
  window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
  const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
    options.success?.();
  });
  window.wx = {
    miniProgram: {
      navigateTo,
    },
  };
  mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
    order: {
      orderId: 'order-wechat-pending-then-paid',
      productId: 'points_60',
      productTitle: '60泥点',
      kind: 'points',
      amountCents: 600,
      status: 'pending' as const,
      paymentChannel: 'wechat_mp',
      paidAt: null as string | null,
      providerTransactionId: null,
      createdAt: '2026-04-25T10:00:00Z',
      pointsDelta: 0,
      membershipExpiresAt: null,
    },
    center: {
      walletBalance: 0,
      membership: {
        status: 'normal',
        tier: 'normal',
        startedAt: null,
        expiresAt: null,
        updatedAt: null,
      },
      pointProducts: [],
      membershipProducts: [],
      benefits: [],
      latestOrder: null,
      hasPointsRecharged: false,
    },
    wechatMiniProgramPayParams: {
      timeStamp: '1777110165',
      nonceStr: 'nonce',
      package: 'prepay_id=wx-prepay',
      signType: 'RSA',
      paySign: 'signature',
    },
  });
  mockConfirmWechatRpgProfileRechargeOrder
    .mockResolvedValueOnce({
      order: {
        orderId: 'order-wechat-pending-then-paid',
        productId: 'points_60',
        productTitle: '60泥点',
        kind: 'points',
        amountCents: 600,
        status: 'pending' as const,
        paymentChannel: 'wechat_mp',
        paidAt: null,
        providerTransactionId: null,
        createdAt: '2026-04-25T10:00:00Z',
        pointsDelta: 0,
        membershipExpiresAt: null,
      },
      center: {
        walletBalance: 0,
        membership: {
          status: 'normal',
          tier: 'normal',
          startedAt: null,
          expiresAt: null,
          updatedAt: null,
        },
        pointProducts: [],
        membershipProducts: [],
        benefits: [],
        latestOrder: null,
        hasPointsRecharged: false,
      },
    })
    .mockResolvedValueOnce({
      order: {
        orderId: 'order-wechat-pending-then-paid',
        productId: 'points_60',
        productTitle: '60泥点',
        kind: 'points',
        amountCents: 600,
        status: 'paid' as const,
        paymentChannel: 'wechat_mp',
        paidAt: '2026-04-25T10:01:00Z',
        providerTransactionId: 'wx-transaction-2',
        createdAt: '2026-04-25T10:00:00Z',
        pointsDelta: 120,
        membershipExpiresAt: null,
      },
      center: {
        walletBalance: 120,
        membership: {
          status: 'normal',
          tier: 'normal',
          startedAt: null,
          expiresAt: null,
          updatedAt: null,
        },
        pointProducts: [],
        membershipProducts: [],
        benefits: [],
        latestOrder: null,
        hasPointsRecharged: true,
      },
    });

  renderProfileView(onRechargeSuccess);
  await openRechargeModal(user);
  await user.click(await screen.findByRole('button', { name: /60泥点/u }));

  const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
  const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
    'requestId',
  );
  expect(requestId).toBeTruthy();
  await act(async () => {
    window.location.hash = `wx_pay_result=${requestId}:success`;
    window.dispatchEvent(new HashChangeEvent('hashchange'));
  });

  expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(1);
  expect(onRechargeSuccess).not.toHaveBeenCalled();

  await waitFor(() => {
    expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(2);
  });

  expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
  expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});

test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => {
  const user = userEvent.setup();
  window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
  window.wx = undefined;
  const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
    options.success?.();
  });
  mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
    order: {
      orderId: 'order-wechat-sdk-1',
      productId: 'points_60',
      productTitle: '60泥点',
      kind: 'points',
      amountCents: 600,
      status: 'pending' as const,
      paymentChannel: 'wechat_mp',
      paidAt: null as string | null,
      providerTransactionId: null,
      createdAt: '2026-04-25T10:00:00Z',
      pointsDelta: 0,
      membershipExpiresAt: null,
    },
    center: {
      walletBalance: 0,
      membership: {
        status: 'normal',
        tier: 'normal',
        startedAt: null,
        expiresAt: null,
        updatedAt: null,
      },
      pointProducts: [],
      membershipProducts: [],
      benefits: [],
      latestOrder: null,
      hasPointsRecharged: false,
    },
    wechatMiniProgramPayParams: {
      timeStamp: '1777110165',
      nonceStr: 'nonce',
      package: 'prepay_id=wx-prepay',
      signType: 'RSA',
      paySign: 'signature',
    },
  });

  renderProfileView();
  await openRechargeModal(user);
  await user.click(await screen.findByRole('button', { name: /60泥点/u }));

  await waitFor(() => {
    const script = document.querySelector<HTMLScriptElement>(
      'script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]',
    );
    expect(script).toBeTruthy();
    window.wx = {
      miniProgram: {
        navigateTo,
      },
    };
    script?.dispatchEvent(new Event('load'));
  });

  await waitFor(() => {
    expect(navigateTo).toHaveBeenCalledWith({
      url: expect.stringContaining('/pages/wechat-pay/index?'),
      success: expect.any(Function),
      fail: expect.any(Function),
    });
  });
  const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
  const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
    'requestId',
  );
  expect(requestId).toBeTruthy();
  act(() => {
    window.location.hash = `wx_pay_result=${requestId}:success`;
    window.dispatchEvent(new HashChangeEvent('hashchange'));
  });
  expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
});

test('profile recharge modal releases submitting state after cancelled wechat pay result', async () => {
  const user = userEvent.setup();
  window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
  const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
    options.success?.();
  });
  window.wx = {
    miniProgram: {
      navigateTo,
    },
  };
  mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
    order: {
      orderId: 'order-wechat-cancel-1',
      productId: 'points_60',
      productTitle: '60泥点',
      kind: 'points',
      amountCents: 600,
      status: 'pending' as const,
      paymentChannel: 'wechat_mp',
      paidAt: null as string | null,
      providerTransactionId: null,
      createdAt: '2026-04-25T10:00:00Z',
      pointsDelta: 0,
      membershipExpiresAt: null,
    },
    center: {
      walletBalance: 0,
      membership: {
        status: 'normal',
        tier: 'normal',
        startedAt: null,
        expiresAt: null,
        updatedAt: null,
      },
      pointProducts: [],
      membershipProducts: [],
      benefits: [],
      latestOrder: null,
      hasPointsRecharged: false,
    },
    wechatMiniProgramPayParams: {
      timeStamp: '1777110165',
      nonceStr: 'nonce',
      package: 'prepay_id=wx-prepay-cancel',
      signType: 'RSA',
      paySign: 'signature',
    },
  });

  renderProfileView();
  await openRechargeModal(user);
  const buyButton = await screen.findByRole('button', { name: /60泥点/u });
  await user.click(buyButton);

  await waitFor(() => {
    expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
      'points_60',
      'wechat_mp',
    );
  });
  expect(
    within(buyButton).getByText('处理中', { selector: 'span' }),
  ).toBeTruthy();

  const requestUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
  const requestId = new URL(`https://mini.test${requestUrl}`).searchParams.get(
    'requestId',
  );
  expect(requestId).toBeTruthy();
  act(() => {
    window.location.hash = `wx_pay_result=${requestId}:cancel`;
    window.dispatchEvent(new HashChangeEvent('hashchange'));
  });

  expect(
    await screen.findByRole('dialog', { name: '支付已取消' }),
  ).toBeTruthy();
  expect(screen.getByText('本次没有扣款，账户状态未发生变化。')).toBeTruthy();
  await waitFor(() => {
    expect(
      within(screen.getByRole('button', { name: /60泥点/u })).getByText(
        '购买',
        { selector: 'span' },
      ),
    ).toBeTruthy();
  });
  expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled();
});

test('profile daily task shortcut opens task center and claims reward', async () => {
  const user = userEvent.setup();
  const onRechargeSuccess = vi.fn();

  renderProfileView(onRechargeSuccess);
  await user.click(screen.getByRole('button', { name: /每日任务/u }));

  expect(await screen.findByText('每日登录')).toBeTruthy();
  expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
  expect(screen.getByText('1/1')).toBeTruthy();
  expect(screen.getByText('+10')).toBeTruthy();

  await user.click(screen.getByRole('button', { name: '领取' }));

  await waitFor(() => {
    expect(mockClaimRpgProfileTaskReward).toHaveBeenCalledWith('daily_login');
  });
  expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
  expect(await screen.findByText('已领取 10 泥点')).toBeTruthy();
  expect(
    (screen.getByRole('button', { name: '已领取' }) as HTMLButtonElement)
      .disabled,
  ).toBe(true);
});

test('profile total play time card always uses hours', () => {
  renderProfileView(vi.fn(), {
    totalPlayTimeMs: 90 * 60 * 1000,
  });

  const playTimeCard = screen.getByRole('button', {
    name: /游戏时长/u,
  });

  expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
  expect(within(playTimeCard).queryByText('90分')).toBeNull();
});

test('profile played works card shows count unit', () => {
  renderProfileView(vi.fn(), {
    playedWorldCount: 1,
  });

  const playedCard = screen.getByRole('button', {
    name: /玩过\s*1个/u,
  });

  expect(within(playedCard).getByText('1个')).toBeTruthy();
});

test('desktop account entry uses saved avatar image when available', () => {
  mockDesktopLayout();
  const avatarUrl = 'data:image/png;base64,AAAA';

  renderProfileView(vi.fn(), {}, { avatarUrl });

  const accountEntry = screen.getByRole('button', { name: /测试玩家/u });
  const avatarImage = accountEntry.querySelector('img');
  expect(avatarImage?.getAttribute('src')).toBe(avatarUrl);
  expect(within(accountEntry).queryByText('测')).toBeNull();
});

test('profile avatar upload uses the shared square crop tool', async () => {
  stubFileReader('data:image/png;base64,avatar-source');
  stubImage(800, 600);

  renderProfileView();

  fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
  fireEvent.change(screen.getByLabelText('上传头像', { selector: 'input' }), {
    target: {
      files: [new File(['x'], 'avatar.png', { type: 'image/png' })],
    },
  });

  await waitFor(() => {
    expect(screen.getByRole('dialog', { name: '裁剪头像' })).toBeTruthy();
  });
  expect(screen.getByLabelText('头像裁剪操作区')).toBeTruthy();
  expect(
    screen.getByRole('button', { name: '拖拽右下角裁剪边界' }),
  ).toBeTruthy();
  expect(screen.queryByText('缩放')).toBeNull();
  expect(screen.queryByText('横向')).toBeNull();
  expect(screen.queryByText('纵向')).toBeNull();
});

test('wallet ledger modal shows empty and error states', async () => {
  const user = userEvent.setup();
  mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });

  renderProfileView();
  await user.click(screen.getByRole('button', { name: /泥点\s*0/u }));
  expect(await screen.findByText('暂无账单记录')).toBeTruthy();

  await user.click(screen.getByLabelText('关闭泥点账单'));
  mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
  await user.click(screen.getByRole('button', { name: /泥点\s*0/u }));

  expect(await screen.findByText('加载失败')).toBeTruthy();
  expect(screen.getByText('重新加载')).toBeTruthy();
});

test('profile invite shortcut shows reward subtitle and invited users', async () => {
  const user = userEvent.setup();

  renderProfileView();

  const inviteButton = screen.getByRole('button', { name: /邀请好友/u });
  expect(within(inviteButton).getByText('双方得30')).toBeTruthy();

  const communityButton = screen.getByRole('button', { name: /玩家社区/u });
  expect(within(communityButton).getByText('每日领福利')).toBeTruthy();

  await user.click(inviteButton);

  expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
  expect(
    await screen.findByText('邀请一个用户注册，双方都可以获得30泥点。'),
  ).toBeTruthy();
  expect(screen.getByText('每日最多获得十次邀请奖励。')).toBeTruthy();
  expect(screen.getByText('成功邀请')).toBeTruthy();
  expect(screen.getByText('被邀请玩家')).toBeTruthy();
  expect(screen.queryByText('已奖')).toBeNull();
  expect(screen.queryByText('今日')).toBeNull();
});

test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => {
  renderProfileView();

  const inviteButton = screen.getByRole('button', { name: /邀请好友/u });
  const redeemButton = await screen.findByRole('button', {
    name: /填邀请码/u,
  });
  const communityButton = screen.getByRole('button', { name: /玩家社区/u });

  expect(
    inviteButton.compareDocumentPosition(redeemButton) &
      Node.DOCUMENT_POSITION_FOLLOWING,
  ).toBeTruthy();
  expect(
    redeemButton.compareDocumentPosition(communityButton) &
      Node.DOCUMENT_POSITION_FOLLOWING,
  ).toBeTruthy();
  expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy();
});

test('profile redeem invite shortcut hides after redeemed or one day old', async () => {
  const user = userEvent.setup();

  mockGetRpgProfileReferralInviteCenter.mockResolvedValueOnce(
    mockBuildReferralCenter({
      invitedUsers: [],
      hasRedeemedCode: true,
      boundInviterUserId: 'user-2',
      boundAt: '2026-05-01T08:00:00Z',
    }),
  );
  const { unmount } = renderProfileView();
  await user.click(screen.getByRole('button', { name: /邀请好友/u }));
  await screen.findByText('成功邀请');
  const firstShortcutRegion = screen.getByRole('region', { name: '常用功能' });
  expect(
    within(firstShortcutRegion).queryByRole('button', { name: /填邀请码/u }),
  ).toBeNull();
  unmount();

  renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' });
  const expiredShortcutRegion = screen.getByRole('region', {
    name: '常用功能',
  });
  expect(
    within(expiredShortcutRegion).queryByRole('button', {
      name: /填邀请码/u,
    }),
  ).toBeNull();
});

test('invite query opens login modal for logged out users', async () => {
  const openLoginModal = vi.fn();
  window.history.replaceState(null, '', '/?inviteCode=spring-2026');

  renderLoggedOutHomeView(openLoginModal);

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

test('invite query opens redeem modal directly for logged in users', async () => {
  window.history.replaceState(null, '', '/?inviteCode=spring-2026');

  renderProfileView();

  const input = await screen.findByLabelText('邀请码');
  expect((input as HTMLInputElement).value).toBe('SPRING2026');
});

test('profile redeem invite modal reads query invite code after login', async () => {
  window.history.replaceState(null, '', '/?inviteCode=spring-2026');

  renderProfileView();

  const input = await screen.findByLabelText('邀请码');

  expect((input as HTMLInputElement).value).toBe('SPRING2026');
});

test('profile redeem invite modal submits code and hides shortcut after success', async () => {
  const user = userEvent.setup();
  const onRechargeSuccess = vi.fn();

  renderProfileView(onRechargeSuccess);

  await user.click(await screen.findByRole('button', { name: /填邀请码/u }));
  const input = await screen.findByLabelText('邀请码');
  await user.type(input, 'spring-2026');
  await user.click(screen.getByRole('button', { name: '提交' }));

  await waitFor(() => {
    expect(mockRedeemRpgProfileReferralInviteCode).toHaveBeenCalledWith(
      'SPRING2026',
    );
  });
  expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
  expect(await screen.findByText('已填写')).toBeTruthy();
  const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
  expect(
    within(shortcutRegion).queryByRole('button', {
      name: /填邀请码/u,
    }),
  ).toBeNull();
});

test('opens reward code modal from profile action on mobile', async () => {
  const user = userEvent.setup();

  renderProfileView();
  await user.click(screen.getByRole('button', { name: /兑换码/u }));

  const modal = await screen.findByPlaceholderText('输入兑换码');
  expect(modal).toBeTruthy();
  expect(screen.getByRole('button', { name: '兑换' })).toBeTruthy();
  expect(screen.getByLabelText('关闭兑换码')).toBeTruthy();
});

test('profile page shows legal entries and ICP record link', async () => {
  const user = userEvent.setup();

  renderProfileView();

  const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
  expect(
    shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'),
  ).toBe(true);
  expect(
    within(shortcutRegion).getByRole('button', { name: /每日任务/u }),
  ).toBeTruthy();
  expect(
    within(shortcutRegion).getByRole('button', { name: /邀请好友/u }),
  ).toBeTruthy();
  expect(
    within(shortcutRegion).getByRole('button', { name: /玩家社区/u }),
  ).toBeTruthy();
  expect(
    within(shortcutRegion).getByRole('button', { name: /反馈/u }),
  ).toBeTruthy();

  const legalRegion = screen.getByRole('region', { name: '法律信息' });
  expect(
    within(legalRegion).getByRole('button', { name: /用户协议/u }),
  ).toBeTruthy();
  expect(
    within(legalRegion).getByRole('button', { name: /隐私政策/u }),
  ).toBeTruthy();
  expect(
    within(legalRegion).getByRole('button', { name: /免责声明/u }),
  ).toBeTruthy();

  const recordLink = within(legalRegion).getByRole('link', {
    name: ICP_RECORD_NUMBER,
  });
  expect(recordLink.getAttribute('href')).toBe(ICP_RECORD_URL);
  expect(recordLink.getAttribute('target')).toBe('_blank');
  expect(recordLink.getAttribute('rel')).toBe('noreferrer');

  await user.click(
    within(legalRegion).getByRole('button', { name: /隐私政策/u }),
  );
  expect(await screen.findByRole('dialog', { name: '隐私政策' })).toBeTruthy();
});

test('shows a reachable login entry outside mobile recommend tab', async () => {
  const user = userEvent.setup();
  const openLoginModal = vi.fn();

  renderLoggedOutHomeView(openLoginModal, {}, 'category');
  await user.click(screen.getByRole('button', { name: '登录' }));

  expect(openLoginModal).toHaveBeenCalledTimes(1);
});

test('logged out bottom nav turns active recommend tab into next action', () => {
  const { container } = renderLoggedOutHomeView(vi.fn());

  const nav = container.querySelector('.platform-bottom-nav');
  expect(nav).toBeTruthy();
  const buttons = within(nav as HTMLElement).getAllByRole('button');

  expect(buttons.map((button) => button.textContent)).toEqual([
    '下一个',
    '创作',
    '发现',
  ]);
  expect(buttons[0]?.querySelector('.lucide-chevron-down')).toBeTruthy();
  expect(buttons[1]?.querySelector('.lucide-sparkles')).toBeTruthy();
  expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
});

test('logged in draft bottom tab shows unread marker', () => {
  const { container } = renderLoggedInHomeView({
    hasUnreadDraftUpdate: true,
    draftTabContent: <div>草稿内容</div>,
  });

  const nav = container.querySelector('.platform-bottom-nav');
  expect(nav).toBeTruthy();
  const draftButton = within(nav as HTMLElement).getByRole('button', {
    name: '草稿，有新草稿',
  });

  expect(draftButton.querySelector('.platform-nav-unread-dot')).toBeTruthy();
});

test('mobile discover search submits public work code', async () => {
  const user = userEvent.setup();
  const onSearchPublicCode = vi.fn();

  renderStatefulLoggedOutHomeView({ onSearchPublicCode });
  await user.click(screen.getByRole('button', { name: '发现' }));

  const searchInput =
    screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
  await user.type(searchInput, 'PZ-PROFILE1{enter}');

  expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
});

test('discover search fuzzy matches public work id, name, author and description', async () => {
  const user = userEvent.setup();
  const onOpenGalleryDetail = vi.fn();
  const onSearchPublicCode = vi.fn();
  const entries = [
    {
      ...puzzlePublicEntry,
      workId: 'puzzle-work-moon-gate',
      profileId: 'puzzle-profile-moon-gate',
      publicWorkCode: 'PZ-MOON01',
      authorDisplayName: '月井守望',
      worldName: '月井机关',
      summaryText: '需要沿着银色水路重新点亮机关。',
    },
    {
      ...puzzlePublicEntry,
      workId: 'puzzle-work-fire-bridge',
      profileId: 'puzzle-profile-fire-bridge',
      publicWorkCode: 'PZ-FIRE02',
      authorDisplayName: '晨风',
      worldName: '火桥谜图',
      summaryText: '跨过熔岩断桥寻找遗失碎片。',
    },
  ] satisfies PlatformPublicGalleryCard[];

  renderStatefulLoggedOutHomeView({
    latestEntries: entries,
    onOpenGalleryDetail,
    onSearchPublicCode,
  });
  await user.click(screen.getByRole('button', { name: '发现' }));
  const discoverPanel = document.getElementById('platform-tab-panel-category');
  if (!discoverPanel) {
    throw new Error('缺少发现面板');
  }

  const searchInput =
    screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
  await user.type(searchInput, 'MOON01{enter}');
  expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
  expect(within(discoverPanel).getByText('月井机关')).toBeTruthy();
  expect(within(discoverPanel).queryByText('火桥谜图')).toBeNull();
  expect(onSearchPublicCode).not.toHaveBeenCalled();

  await user.clear(searchInput);
  await user.type(searchInput, '火桥{enter}');
  expect(await within(discoverPanel).findByText('火桥谜图')).toBeTruthy();
  expect(within(discoverPanel).queryByText('月井机关')).toBeNull();

  await user.clear(searchInput);
  await user.type(searchInput, '月井守望{enter}');
  expect(await within(discoverPanel).findByText('月井机关')).toBeTruthy();
  expect(within(discoverPanel).queryByText('火桥谜图')).toBeNull();

  await user.clear(searchInput);
  await user.type(searchInput, '熔岩断桥{enter}');
  expect(await within(discoverPanel).findByText('火桥谜图')).toBeTruthy();
  expect(within(discoverPanel).queryByText('月井机关')).toBeNull();

  await user.click(screen.getByRole('button', { name: /火桥谜图/u }));
  expect(onOpenGalleryDetail).toHaveBeenCalledWith(entries[1]);
});

test('mobile discover keeps edutainment works in the last dedicated channel only', async () => {
  const user = userEvent.setup();
  const onSearchPublicCode = vi.fn();
  const generalEntry = buildTaggedPuzzleEntry('normal01', '普通拼图作品', [
    '儿童教育',
  ]);
  const edutainmentEntry = buildTaggedPuzzleEntry(
    'edu001',
    '儿童动作热身 Demo',
    ['运动', '安全', '拼图', '寓教于乐'],
    {
      playCount: 99,
      remixCount: 30,
      likeCount: 50,
      recentPlayCount7d: 88,
      publishedAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      summaryText: '寓教于乐专属内容',
    },
  );

  renderStatefulLoggedOutHomeView({
    latestEntries: [edutainmentEntry, generalEntry],
    onSearchPublicCode,
  });
  await user.click(screen.getByRole('button', { name: '发现' }));
  const discoverPanel = document.getElementById('platform-tab-panel-category');
  if (!discoverPanel) {
    throw new Error('缺少发现面板');
  }

  const channels = Array.from(
    discoverPanel.querySelectorAll('.platform-mobile-home-channel'),
  ).map((button) => button.textContent);
  expect(channels).toEqual(['推荐', '今日', '分类', '排行', '寓教于乐']);
  expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy();
  expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();

  await user.click(screen.getByRole('button', { name: '今日' }));
  expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();

  await user.click(screen.getByRole('button', { name: '分类' }));
  expect(screen.getByRole('button', { name: '儿童教育' })).toBeTruthy();
  expect(screen.queryByRole('button', { name: '寓教于乐' })).toBeTruthy();
  expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();

  await user.click(screen.getByRole('button', { name: '排行' }));
  expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();

  await user.click(screen.getByRole('button', { name: '寓教于乐' }));
  expect(
    within(discoverPanel).getByRole('button', {
      name: /儿童动作热身 Demo/u,
    }),
  ).toBeTruthy();
  expect(within(discoverPanel).queryByText('普通拼图作品')).toBeNull();

  const searchInput =
    screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
  await user.type(searchInput, '儿童动作热身{enter}');
  expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
  expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();
  expect(onSearchPublicCode).not.toHaveBeenCalled();
});

test('mobile discover hides edutainment channel and work when switch is disabled', async () => {
  vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
  const user = userEvent.setup();
  const onSearchPublicCode = vi.fn();
  const edutainmentEntry = buildTaggedPuzzleEntry(
    'eduoff1',
    '关闭后隐藏的热身 Demo',
    ['寓教于乐'],
    {
      summaryText: '关闭后不可见',
      publishedAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    },
  );

  renderStatefulLoggedOutHomeView({
    latestEntries: [edutainmentEntry],
    onSearchPublicCode,
  });
  await user.click(screen.getByRole('button', { name: '发现' }));
  const discoverPanel = document.getElementById('platform-tab-panel-category');
  if (!discoverPanel) {
    throw new Error('缺少发现面板');
  }

  const channels = Array.from(
    discoverPanel.querySelectorAll('.platform-mobile-home-channel'),
  ).map((button) => button.textContent);
  expect(channels).toEqual(['推荐', '今日', '分类', '排行']);
  expect(within(discoverPanel).queryByText('关闭后隐藏的热身 Demo')).toBeNull();

  const searchInput =
    screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
  await user.type(searchInput, 'PZ-EDUOFF1{enter}');
  expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
  expect(within(discoverPanel).queryByText('关闭后隐藏的热身 Demo')).toBeNull();
  expect(onSearchPublicCode).not.toHaveBeenCalled();
});

test('mobile discover keeps baby object match works in edutainment channel only', async () => {
  const user = userEvent.setup();
  const onSearchPublicCode = vi.fn();
  const onOpenGalleryDetail = vi.fn();
  const babyObjectMatchEntry = buildBabyObjectMatchEntry(
    'baby01',
    '宝贝识物水果篮',
  );
  const generalEntry = buildTaggedPuzzleEntry('normal02', '普通拼图作品', [
    '儿童教育',
  ]);

  renderStatefulLoggedOutHomeView({
    latestEntries: [babyObjectMatchEntry, generalEntry],
    onOpenGalleryDetail,
    onSearchPublicCode,
  });
  await user.click(screen.getByRole('button', { name: '发现' }));
  const discoverPanel = document.getElementById('platform-tab-panel-category');
  if (!discoverPanel) {
    throw new Error('缺少发现面板');
  }

  expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy();
  expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();

  await user.click(screen.getByRole('button', { name: '寓教于乐' }));
  const babyObjectMatchButton = within(discoverPanel).getByRole('button', {
    name: /宝贝识物水果篮/u,
  });
  expect(within(babyObjectMatchButton).getByText('宝贝识物')).toBeTruthy();
  expect(within(discoverPanel).queryByText('普通拼图作品')).toBeNull();

  await user.click(babyObjectMatchButton);
  expect(onOpenGalleryDetail).toHaveBeenCalledWith(babyObjectMatchEntry);

  const searchInput =
    screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
  await user.type(searchInput, '宝贝识物水果篮{enter}');
  expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
  expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
  expect(onSearchPublicCode).not.toHaveBeenCalled();
});

test('discover search keeps public code fallback when local works do not match', async () => {
  const user = userEvent.setup();
  const onSearchPublicCode = vi.fn();

  renderStatefulLoggedOutHomeView({
    latestEntries: [puzzlePublicEntry],
    onSearchPublicCode,
  });
  await user.click(screen.getByRole('button', { name: '发现' }));

  const searchInput =
    screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
  await user.type(searchInput, 'CW-REMOTE-ONLY{enter}');

  expect(onSearchPublicCode).toHaveBeenCalledWith('CW-REMOTE-ONLY');
  expect(screen.queryByText('搜索结果')).toBeNull();
});

test('public gallery cards hide work code until detail is opened', async () => {
  const user = userEvent.setup();
  const onOpenGalleryDetail = vi.fn();

  renderStatefulLoggedOutHomeView({
    latestEntries: [puzzlePublicEntry],
    onOpenGalleryDetail,
  });
  await user.click(screen.getByRole('button', { name: '发现' }));

  expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
  expect(
    screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
  ).toBeNull();

  await user.click(screen.getByRole('button', { name: /奇幻拼图/u }));

  expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
});

test('logged out mobile shell defaults to discover tab', () => {
  const { container } = renderStatefulLoggedOutHomeView({
    latestEntries: [puzzlePublicEntry],
  });

  const activePanel = container.querySelector('.platform-tab-panel--active');
  expect(activePanel?.id).toBe('platform-tab-panel-category');
  expect(
    screen.getByPlaceholderText('搜索作品号、名称、作者、描述'),
  ).toBeTruthy();
  expect(container.querySelector('.platform-mobile-topbar')).toBeTruthy();
  expect(
    container.querySelector('.platform-mobile-entry-shell--recommend'),
  ).toBeNull();
});

test('logged out recommend tab opens login modal and shows cover only', async () => {
  const user = userEvent.setup();
  const { container, openLoginModal } = renderStatefulLoggedOutHomeView({
    latestEntries: [puzzlePublicEntry],
    activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
  });
  const bottomNav = container.querySelector('.platform-bottom-nav');
  if (!bottomNav) {
    throw new Error('缺少底部导航');
  }

  await user.click(
    within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
  );

  expect(openLoginModal).toHaveBeenCalledTimes(1);
  expect(
    container.querySelector('.platform-recommend-cover-only'),
  ).toBeTruthy();
  expect(container.querySelector('.platform-mobile-topbar')).toBeNull();
  expect(
    container.querySelector('.platform-mobile-entry-shell--recommend'),
  ).toBeTruthy();
  expect(screen.queryByTestId('recommend-runtime')).toBeNull();
  expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull();
  expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
});

test('logged out recommend cover opens login modal again', async () => {
  const user = userEvent.setup();
  const onOpenGalleryDetail = vi.fn();
  const { openLoginModal } = renderStatefulLoggedOutHomeView({
    latestEntries: [puzzlePublicEntry],
    activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
    onOpenGalleryDetail,
  });
  const bottomNav = document.querySelector('.platform-bottom-nav');
  if (!bottomNav) {
    throw new Error('缺少底部导航');
  }

  await user.click(
    within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
  );
  await user.click(
    screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }),
  );

  expect(openLoginModal).toHaveBeenCalledTimes(2);
  expect(openLoginModal).toHaveBeenLastCalledWith();
  expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});

test('logged out desktop recommend page renders cover only', () => {
  mockDesktopLayout();
  renderLoggedOutHomeView(vi.fn(), {
    latestEntries: [puzzlePublicEntry],
    activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
  });

  expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
  expect(screen.queryByText('今日游戏')).toBeNull();
  expect(screen.queryByText('作品分类')).toBeNull();
  expect(screen.queryByTestId('recommend-runtime')).toBeNull();
});

test('logged in recommend page uses gated recommend detail callback', async () => {
  const user = userEvent.setup();
  const onOpenGalleryDetail = vi.fn();
  const onOpenRecommendGalleryDetail = vi.fn();

  render(
    <AuthUiContext.Provider
      value={{
        user: {
          id: 'user-1',
          publicUserCode: '100001',
          username: 'tester',
          displayName: '测试玩家',
          avatarUrl: null,
          phoneNumberMasked: null,
          loginMethod: 'password',
          bindingStatus: 'active',
          wechatBound: false,
          createdAt: new Date().toISOString(),
        },
        canAccessProtectedData: true,
        openLoginModal: vi.fn(),
        requireAuth: (action) => action(),
        openSettingsModal: vi.fn(),
        openAccountModal: vi.fn(),
        setCurrentUser: vi.fn(),
        logout: vi.fn(async () => undefined),
        musicVolume: 0.42,
        setMusicVolume: vi.fn(),
        platformTheme: 'light',
        setPlatformTheme: vi.fn(),
        isHydratingSettings: false,
        isPersistingSettings: false,
        settingsError: null,
      }}
    >
      <RpgEntryHomeView
        activeTab="home"
        onTabChange={vi.fn()}
        hasSavedGame={false}
        savedSnapshot={null}
        saveEntries={[]}
        saveError={null}
        featuredEntries={[]}
        latestEntries={[puzzlePublicEntry]}
        myEntries={[]}
        historyEntries={[]}
        profileDashboard={null}
        isLoadingPlatform={false}
        isLoadingDashboard={false}
        isResumingSaveWorldKey={null}
        platformError={null}
        dashboardError={null}
        onContinueGame={vi.fn()}
        onResumeSave={vi.fn()}
        onOpenCreateWorld={vi.fn()}
        onOpenCreateTypePicker={vi.fn()}
        onOpenGalleryDetail={onOpenGalleryDetail}
        onOpenRecommendGalleryDetail={onOpenRecommendGalleryDetail}
        recommendRuntimeError="作品暂时无法进入，请稍后再试。"
        activeRecommendEntryKey="puzzle:user-2:puzzle-profile-public-1"
        onOpenLibraryDetail={vi.fn()}
        onSearchPublicCode={vi.fn()}
      />
    </AuthUiContext.Provider>,
  );

  await user.click(screen.getByText('作品暂时无法进入，请稍后再试。'));

  expect(onOpenRecommendGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
  expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});

test('logged out mobile recommend page renders cover instead of runtime', () => {
  const onOpenGalleryDetail = vi.fn();
  renderLoggedOutHomeView(
    vi.fn(),
    {
      latestEntries: [puzzlePublicEntry],
      activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
      onOpenGalleryDetail,
    },
    'home',
  );

  expect(screen.queryByTestId('recommend-runtime')).toBeNull();
  expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
  expect(
    document.querySelector('.platform-public-work-card__cover'),
  ).toBeNull();
  expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
  fireEvent.click(screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }));
  expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});

test('mobile recommend loading state is themed instead of hardcoded black', () => {
  renderLoggedOutHomeView(vi.fn(), {
    latestEntries: [puzzlePublicEntry],
    activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
    isStartingRecommendEntry: true,
    recommendRuntimeContent: null,
  });

  expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
});

test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => {
  vi.useFakeTimers();
  const onSelectNextRecommendEntry = vi.fn();
  const onSelectPreviousRecommendEntry = vi.fn();
  const onLikeRecommendEntry = vi.fn();
  const onRemixRecommendEntry = vi.fn();
  const firstEntry = {
    ...puzzlePublicEntry,
    workId: 'puzzle-work-feed-1',
    profileId: 'puzzle-profile-feed-1',
    ownerUserId: 'user-feed-1',
    publicWorkCode: 'PZ-FEED1',
    worldName: '当前拼图',
    coverImageSrc: 'current-cover.png',
  } satisfies PlatformPublicGalleryCard;
  const secondEntry = {
    ...puzzlePublicEntry,
    workId: 'puzzle-work-feed-2',
    profileId: 'puzzle-profile-feed-2',
    ownerUserId: 'user-feed-2',
    publicWorkCode: 'PZ-FEED2',
    worldName: '下一拼图',
    coverImageSrc: 'next-cover.png',
  } satisfies PlatformPublicGalleryCard;
  const thirdEntry = {
    ...puzzlePublicEntry,
    workId: 'puzzle-work-feed-3',
    profileId: 'puzzle-profile-feed-3',
    ownerUserId: 'user-feed-3',
    publicWorkCode: 'PZ-FEED3',
    worldName: '上一拼图',
    coverImageSrc: 'previous-cover.png',
  } satisfies PlatformPublicGalleryCard;

  render(
    <AuthUiContext.Provider
      value={{
        user: {
          id: 'user-1',
          publicUserCode: '100001',
          username: 'tester',
          displayName: '测试玩家',
          avatarUrl: null,
          phoneNumberMasked: null,
          loginMethod: 'password',
          bindingStatus: 'active',
          wechatBound: false,
          createdAt: new Date().toISOString(),
        },
        canAccessProtectedData: true,
        openLoginModal: vi.fn(),
        requireAuth: (action) => action(),
        openSettingsModal: vi.fn(),
        openAccountModal: vi.fn(),
        setCurrentUser: vi.fn(),
        logout: vi.fn(async () => undefined),
        musicVolume: 0.42,
        setMusicVolume: vi.fn(),
        platformTheme: 'light',
        setPlatformTheme: vi.fn(),
        isHydratingSettings: false,
        isPersistingSettings: false,
        settingsError: null,
      }}
    >
      <RpgEntryHomeView
        activeTab="home"
        onTabChange={vi.fn()}
        hasSavedGame={false}
        savedSnapshot={null}
        saveEntries={[]}
        saveError={null}
        featuredEntries={[]}
        latestEntries={[firstEntry, secondEntry, thirdEntry]}
        myEntries={[]}
        historyEntries={[]}
        profileDashboard={null}
        isLoadingPlatform={false}
        isLoadingDashboard={false}
        isResumingSaveWorldKey={null}
        platformError={null}
        dashboardError={null}
        onContinueGame={vi.fn()}
        onResumeSave={vi.fn()}
        onOpenCreateWorld={vi.fn()}
        onOpenCreateTypePicker={vi.fn()}
        onOpenGalleryDetail={vi.fn()}
        recommendRuntimeContent={<div data-testid="recommend-runtime" />}
        activeRecommendEntryKey="puzzle:user-feed-1:puzzle-profile-feed-1"
        onSelectNextRecommendEntry={onSelectNextRecommendEntry}
        onSelectPreviousRecommendEntry={onSelectPreviousRecommendEntry}
        onLikeRecommendEntry={onLikeRecommendEntry}
        onRemixRecommendEntry={onRemixRecommendEntry}
        onOpenLibraryDetail={vi.fn()}
        onSearchPublicCode={vi.fn()}
      />
    </AuthUiContext.Provider>,
  );

  expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
  expect(
    document.querySelectorAll('.platform-recommend-runtime-preview'),
  ).toHaveLength(2);
  expect(
    document.querySelectorAll('.platform-recommend-swipe-card'),
  ).toHaveLength(3);
  expect(screen.getAllByText('下一拼图').length).toBeGreaterThanOrEqual(2);
  expect(screen.getAllByText('上一拼图').length).toBeGreaterThanOrEqual(2);
  expect(screen.queryByText('评论')).toBeNull();
  expect(screen.queryByLabelText(/游玩/u)).toBeNull();
  const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
  Object.defineProperty(navigator, 'clipboard', {
    configurable: true,
    value: { writeText: clipboardWriteText },
  });

  const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
  expect(meta.closest('[data-recommend-swipe-zone="true"]')).toBeTruthy();
  const activeRecommendCard = within(meta);
  const likeButton = activeRecommendCard.getByRole('button', {
    name: '点赞 12',
  });
  expect(likeButton).toBeTruthy();
  expect(activeRecommendCard.getByLabelText('12 个赞')).toBeTruthy();
  const shareButton = activeRecommendCard.getByRole('button', { name: '分享' });
  const remixButton = activeRecommendCard.getByRole('button', {
    name: '改造 5',
  });
  expect(shareButton).toBeTruthy();
  expect(remixButton).toBeTruthy();

  fireEvent.click(likeButton);
  fireEvent.click(shareButton);
  fireEvent.click(remixButton);

  expect(onLikeRecommendEntry).toHaveBeenCalledWith(firstEntry);
  expect(onRemixRecommendEntry).toHaveBeenCalledWith(firstEntry);
  expect(clipboardWriteText).toHaveBeenCalledWith(
    expect.stringContaining('作品号：PZ-FEED1'),
  );

  act(() => {
    dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 300 });
    dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 210 });
  });

  const rail = document.querySelector(
    '.platform-recommend-swipe-rail',
  ) as HTMLElement | null;
  expect(rail?.className).toContain('platform-recommend-swipe-rail');

  act(() => {
    dispatchPointerEvent(meta, 'pointerup', { pointerId: 1, clientY: 210 });
    vi.advanceTimersByTime(180);
  });

  expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
  expect(onSelectPreviousRecommendEntry).not.toHaveBeenCalled();
  vi.useRealTimers();
});

test('logged out active recommend bottom tab selects next work without login', async () => {
  const user = userEvent.setup();
  const onSelectNextRecommendEntry = vi.fn();
  const openLoginModal = vi.fn();

  renderLoggedOutHomeView(openLoginModal, {
    latestEntries: [puzzlePublicEntry],
    activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
    onSelectNextRecommendEntry,
  });

  await user.click(screen.getByRole('button', { name: '下一个' }));

  expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
  expect(openLoginModal).not.toHaveBeenCalled();
});

test('mobile recommend meta loads real author avatar from public user summary', async () => {
  mockGetPublicAuthUserById.mockResolvedValueOnce({
    id: 'user-2',
    publicUserCode: 'SY-00000002',
    displayName: '拼图玩家',
    avatarUrl: 'data:image/png;base64,AUTHOR',
  });

  renderLoggedOutHomeView(vi.fn(), {
    featuredEntries: [puzzlePublicEntry],
    latestEntries: [puzzlePublicEntry],
    activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
  });

  await waitFor(() => {
    expect(
      document
        .querySelector('.platform-recommend-cover-only__author img')
        ?.getAttribute('src'),
    ).toBe('data:image/png;base64,AUTHOR');
  });
  expect(mockGetPublicAuthUserById).toHaveBeenCalledTimes(1);
  expect(mockGetPublicAuthUserById).toHaveBeenCalledWith('user-2');
  expect(mockGetPublicAuthUserByCode).not.toHaveBeenCalled();
});

test('mobile discover recommend feed only rotates the card closest to screen center', async () => {
  vi.useFakeTimers();
  Object.defineProperty(window, 'requestAnimationFrame', {
    configurable: true,
    writable: true,
    value: (callback: FrameRequestCallback) =>
      window.setTimeout(() => callback(0), 0),
  });
  Object.defineProperty(window, 'cancelAnimationFrame', {
    configurable: true,
    writable: true,
    value: (handle: number) => window.clearTimeout(handle),
  });

  const firstEntry = buildCarouselPuzzleEntry(
    'center1',
    '中心拼图一',
    'center-one',
  );
  const secondEntry = buildCarouselPuzzleEntry(
    'center2',
    '中心拼图二',
    'center-two',
  );
  const cardRects = new Map<string, DOMRect>();

  renderStatefulLoggedOutHomeView({
    latestEntries: [firstEntry, secondEntry],
  });
  act(() => {
    screen.getByRole('button', { name: '发现' }).click();
  });

  const tabPanel = document.querySelector('.platform-tab-panel--active');
  const firstCard = screen.getByRole('button', { name: /中心拼图一/u });
  const secondCard = screen.getByRole('button', { name: /中心拼图二/u });
  if (!tabPanel) {
    throw new Error('缺少移动端首页滚动面板');
  }

  tabPanel.getBoundingClientRect = vi.fn(
    () =>
      ({
        top: 0,
        bottom: 600,
        height: 600,
        left: 0,
        right: 360,
        width: 360,
      }) as DOMRect,
  );
  firstCard.getBoundingClientRect = vi.fn(() => cardRects.get('first')!);
  secondCard.getBoundingClientRect = vi.fn(() => cardRects.get('second')!);
  cardRects.set('first', {
    top: 170,
    bottom: 370,
    height: 200,
    left: 0,
    right: 320,
    width: 320,
  } as DOMRect);
  cardRects.set('second', {
    top: 420,
    bottom: 620,
    height: 200,
    left: 0,
    right: 320,
    width: 320,
  } as DOMRect);

  act(() => {
    vi.runOnlyPendingTimers();
  });

  expect(within(firstCard).getByRole('img').getAttribute('src')).toBe(
    'center-one-1.png',
  );
  expect(within(secondCard).getByRole('img').getAttribute('src')).toBe(
    'center-two-1.png',
  );

  act(() => {
    vi.advanceTimersByTime(4200);
  });

  expect(within(firstCard).getByRole('img').getAttribute('src')).toBe(
    'center-one-2.png',
  );
  expect(within(secondCard).getByRole('img').getAttribute('src')).toBe(
    'center-two-1.png',
  );

  cardRects.set('first', {
    top: -120,
    bottom: 80,
    height: 200,
    left: 0,
    right: 320,
    width: 320,
  } as DOMRect);
  cardRects.set('second', {
    top: 200,
    bottom: 400,
    height: 200,
    left: 0,
    right: 320,
    width: 320,
  } as DOMRect);

  act(() => {
    tabPanel.dispatchEvent(new Event('scroll'));
    vi.runOnlyPendingTimers();
  });

  expect(within(firstCard).getByRole('img').getAttribute('src')).toBe(
    'center-one-1.png',
  );

  act(() => {
    vi.advanceTimersByTime(4200);
  });

  expect(within(secondCard).getByRole('img').getAttribute('src')).toBe(
    'center-two-2.png',
  );
});

test('mobile today channel only shows newly published works from today', async () => {
  const user = userEvent.setup();
  const now = new Date();
  const todayPublishedAt = new Date(
    now.getFullYear(),
    now.getMonth(),
    now.getDate(),
    10,
  ).toISOString();
  const yesterdayPublishedAt = new Date(
    now.getFullYear(),
    now.getMonth(),
    now.getDate() - 1,
    10,
  ).toISOString();
  const todayEntry = {
    ...puzzlePublicEntry,
    workId: 'puzzle-work-today',
    profileId: 'puzzle-profile-today',
    publicWorkCode: 'PZ-TODAY1',
    worldName: '今日新游',
    publishedAt: todayPublishedAt,
    updatedAt: todayPublishedAt,
  } satisfies PlatformPublicGalleryCard;
  const yesterdayEntry = {
    ...puzzlePublicEntry,
    workId: 'puzzle-work-yesterday',
    profileId: 'puzzle-profile-yesterday',
    publicWorkCode: 'PZ-YDAY01',
    worldName: '昨日旧作',
    publishedAt: yesterdayPublishedAt,
    updatedAt: yesterdayPublishedAt,
  } satisfies PlatformPublicGalleryCard;
  const updatedTodayEntry = {
    ...puzzlePublicEntry,
    workId: 'puzzle-work-updated-today',
    profileId: 'puzzle-profile-updated-today',
    publicWorkCode: 'PZ-UPDAY1',
    worldName: '今日更新旧作',
    publishedAt: yesterdayPublishedAt,
    updatedAt: todayPublishedAt,
  } satisfies PlatformPublicGalleryCard;

  renderStatefulLoggedOutHomeView({
    latestEntries: [yesterdayEntry, updatedTodayEntry, todayEntry],
  });

  await user.click(screen.getByRole('button', { name: '发现' }));
  await user.click(screen.getByRole('button', { name: '今日' }));
  const discoverPanel = document.getElementById('platform-tab-panel-category');
  if (!discoverPanel) {
    throw new Error('缺少发现面板');
  }

  expect(
    within(discoverPanel).getByRole('button', { name: /今日新游/u }),
  ).toBeTruthy();
  expect(within(discoverPanel).queryByText('昨日旧作')).toBeNull();
  expect(within(discoverPanel).queryByText('今日更新旧作')).toBeNull();
});

test('desktop logged in home syncs mobile home modules without square or latest labels', () => {
  mockDesktopLayout();
  const todayPublishedAt = new Date().toISOString();
  const todayEntry = {
    ...puzzlePublicEntry,
    workId: 'puzzle-work-desktop-today',
    profileId: 'puzzle-profile-desktop-today',
    publicWorkCode: 'PZ-DTODAY',
    worldName: '桌面今日新游',
    publishedAt: todayPublishedAt,
    updatedAt: todayPublishedAt,
  } satisfies PlatformPublicGalleryCard;

  render(
    <AuthUiContext.Provider
      value={{
        user: {
          id: 'user-1',
          publicUserCode: '100001',
          username: 'tester',
          displayName: '测试玩家',
          avatarUrl: null,
          phoneNumberMasked: null,
          loginMethod: 'password',
          bindingStatus: 'active',
          wechatBound: false,
          createdAt: new Date().toISOString(),
        },
        canAccessProtectedData: true,
        openLoginModal: vi.fn(),
        requireAuth: (action) => action(),
        openSettingsModal: vi.fn(),
        openAccountModal: vi.fn(),
        setCurrentUser: vi.fn(),
        logout: vi.fn(async () => undefined),
        musicVolume: 0.42,
        setMusicVolume: vi.fn(),
        platformTheme: 'light',
        setPlatformTheme: vi.fn(),
        isHydratingSettings: false,
        isPersistingSettings: false,
        settingsError: null,
      }}
    >
      <RpgEntryHomeView
        activeTab="home"
        onTabChange={vi.fn()}
        hasSavedGame={false}
        savedSnapshot={null}
        saveEntries={[]}
        saveError={null}
        featuredEntries={[]}
        latestEntries={[puzzlePublicEntry, todayEntry]}
        myEntries={[]}
        historyEntries={[]}
        profileDashboard={null}
        isLoadingPlatform={false}
        isLoadingDashboard={false}
        isResumingSaveWorldKey={null}
        platformError={null}
        dashboardError={null}
        onContinueGame={vi.fn()}
        onResumeSave={vi.fn()}
        onOpenCreateWorld={vi.fn()}
        onOpenCreateTypePicker={vi.fn()}
        onOpenGalleryDetail={vi.fn()}
        onOpenLibraryDetail={vi.fn()}
        onSearchPublicCode={vi.fn()}
      />
    </AuthUiContext.Provider>,
  );

  expect(screen.getByText('今日游戏')).toBeTruthy();
  expect(screen.getAllByText('推荐').length).toBeGreaterThan(0);
  expect(screen.getByText('作品分类')).toBeTruthy();
  expect(screen.getAllByText('桌面今日新游').length).toBeGreaterThan(0);
  expect(screen.queryByText('趋势关注')).toBeNull();
  expect(screen.queryByText('最新发布')).toBeNull();
  expect(screen.queryByText('作品广场')).toBeNull();
  expect(screen.queryByText('公开作品')).toBeNull();
  expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
  expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
  expect(screen.queryByText('1777110165.990127Z')).toBeNull();
});

test('mobile home moves category shelf into game category channel', async () => {
  const user = userEvent.setup();
  const { container } = renderStatefulLoggedOutHomeView({
    latestEntries: [puzzlePublicEntry],
  });

  expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull();
  expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();

  await user.click(screen.getByRole('button', { name: '发现' }));
  await user.click(screen.getByRole('button', { name: '分类' }));

  expect(screen.getAllByText('分类').length).toBeGreaterThan(0);
  expect(screen.getByRole('button', { name: /筛选/u })).toBeTruthy();
  expect(screen.getByRole('button', { name: '奇幻' })).toBeTruthy();
  expect(screen.getByRole('button', { name: /奇幻拼图，试玩/u })).toBeTruthy();
  expect(container.querySelector('.platform-category-game-list')).toBeTruthy();
  expect(container.querySelector('.platform-category-game-item')).toBeTruthy();
  expect(
    container.querySelector('.platform-category-game-item__action')
      ?.textContent,
  ).toBe('试玩');
});

test('mobile game category list orders works by composite public metric', async () => {
  const user = userEvent.setup();

  renderStatefulLoggedOutHomeView({
    latestEntries: [puzzlePublicEntry, hotRankEntry],
  });

  await user.click(screen.getByRole('button', { name: '发现' }));
  await user.click(screen.getByRole('button', { name: '分类' }));
  await user.click(screen.getByRole('button', { name: '奇幻' }));

  const gameItems = Array.from(
    document.querySelectorAll('.platform-category-game-item__title'),
  ).map((element) => element.textContent);
  expect(gameItems).toEqual(['热门高分拼图', '奇幻拼图']);
});

test('bottom category tab becomes ranking and switches ranking metrics', async () => {
  const user = userEvent.setup();

  renderStatefulLoggedOutHomeView({
    latestEntries: [remixRankEntry, hotRankEntry, newRankEntry],
  });

  await user.click(screen.getByRole('button', { name: '发现' }));
  await user.click(screen.getByRole('button', { name: '排行' }));

  expect(await screen.findByRole('tab', { name: '热门榜' })).toBeTruthy();
  expect(screen.getByRole('tab', { name: '改造榜' })).toBeTruthy();
  expect(screen.getByRole('tab', { name: '新品榜' })).toBeTruthy();
  expect(screen.getByRole('tab', { name: '点赞榜' })).toBeTruthy();

  const rankingPanel = document.getElementById('platform-tab-panel-category');
  expect(rankingPanel?.getAttribute('aria-hidden')).toBe('false');
  expect(within(rankingPanel!).getByText('热门高分拼图')).toBeTruthy();
  expect(within(rankingPanel!).getByText('40')).toBeTruthy();
  expect(within(rankingPanel!).getAllByText('游玩').length).toBeGreaterThan(0);

  await user.click(screen.getByRole('tab', { name: '改造榜' }));

  expect(within(rankingPanel!).getByText('改造高分拼图')).toBeTruthy();
  expect(within(rankingPanel!).getByText('18')).toBeTruthy();
  expect(within(rankingPanel!).getAllByText('改造').length).toBeGreaterThan(0);

  await user.click(screen.getByRole('tab', { name: '新品榜' }));

  expect(within(rankingPanel!).getByText('新品增长拼图')).toBeTruthy();
  expect(within(rankingPanel!).getByText('9')).toBeTruthy();
  expect(within(rankingPanel!).getAllByText('近7日').length).toBeGreaterThan(0);
});

test('ranking rows limit displayed work name and show two short tags on the third line', async () => {
  const user = userEvent.setup();

  renderStatefulLoggedOutHomeView({
    latestEntries: [longTextRankEntry],
  });

  await user.click(screen.getByRole('button', { name: '发现' }));
  await user.click(screen.getByRole('button', { name: '排行' }));

  const rankingPanel = document.getElementById('platform-tab-panel-category');
  expect(rankingPanel).toBeTruthy();
  expect(within(rankingPanel!).getByText('关键词逍遥游拼图')).toBeTruthy();
  expect(within(rankingPanel!).queryByText('关键词逍遥游拼图关卡')).toBeNull();
  expect(within(rankingPanel!).getByText('逍遥游拼')).toBeTruthy();
  expect(within(rankingPanel!).getByText('古风机关')).toBeTruthy();
  expect(within(rankingPanel!).queryByText(/2026-04-29/u)).toBeNull();
  expect(within(rankingPanel!).queryByText('拼图玩家')).toBeNull();
});
