﻿import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CharacterScene, ChatScene } from '@/pages/NiTu';
import { useAuthStore } from '@/stores/auth';
import { useOpencodeStore } from '@/stores/opencode';
import { usePartnerProfilesStore } from '@/stores/partner-profiles';
import { useProjectTemplatesStore } from '@/stores/project-templates';
import { useProviderStore, type ProviderAccount, type ProviderWithKeyInfo } from '@/stores/providers';
import { createProjectTemplateSnapshot } from '../../shared/project-template';

const hostApiFetchMock = vi.fn();
const projectTemplateReadOverrideMock = vi.fn();

vi.mock('@/lib/host-api', () => ({
  hostApiFetch: async (...args: unknown[]) => {
    const override = await projectTemplateReadOverrideMock(...args);
    if (override !== undefined) return override;

    const [path] = args;
    if (typeof path === 'string' && path.startsWith('/api/opencode/projects/template?')) {
      const projectId = new URL(path, 'https://niancode.test').searchParams.get('projectId') ?? '';
      const snapshot = useProjectTemplatesStore.getState().snapshotsByProjectId[projectId];
      return snapshot ? { status: 'valid', snapshot } : { status: 'missing' };
    }

    return hostApiFetchMock(...args);
  },
  ensureHostApiToken: vi.fn().mockResolvedValue('test-token'),
}));

vi.mock('@/lib/api-client', () => ({
  invokeIpc: vi.fn(),
}));

const activeProject = {
  id: 'prj_1',
  path: 'D:/repo/packages/ui',
  name: 'ui',
  createdAt: '2026-05-12T00:00:00.000Z',
  updatedAt: '2026-05-12T00:00:00.000Z',
  lastOpenedAt: '2026-05-12T00:00:00.000Z',
};

function resetOpencodeStore() {
  useOpencodeStore.setState({
    status: { state: 'stopped', port: 4096 },
    health: null,
    healthCheckedAt: null,
    runtimeConfigSummary: null,
    projects: [],
    activeProject: null,
    sessions: [],
    sessionsByProjectId: {},
    selectedSessionId: null,
    sessionStatuses: {},
    sessionMessages: [],
    sessionMessagesBySessionId: {},
    streamingMessage: null,
    streamingMessagesBySessionId: {},
    streamingTools: [],
    streamingToolsBySessionId: {},
    sendingSessionId: null,
    sendingSessionIds: {},
    queuedSessionPrompts: {},
    pendingQuestions: [],
    pendingPermissions: [],
    sessionTodos: [],
    sessionTodosBySessionId: {},
    sessionTodosLoading: false,
    sessionTodosError: null,
    revertedSessionIds: {},
    sessionDiffs: [],
    sessionDiffLoading: false,
    sessionDiffError: null,
    fileStatuses: [],
    fileSearchResults: [],
    contentSearchResults: [],
    selectedContextFile: null,
    fileContextLoading: false,
    fileContextError: null,
    runtimeAutoStartAttempted: false,
    loading: false,
    error: null,
  });
}

function getSkillCountLabel(container: HTMLElement, count: number) {
  return within(container).getByText((_, element) => element?.textContent === `${count} 个技能`);
}

function _mockOpencodeRuntime() {
  hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
    if (path === '/api/opencode/status') {
      return { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' };
    }
    if (path === '/api/opencode/projects') {
      return { projects: [activeProject], activeProject };
    }
    if (path === `/api/opencode/projects/template?projectId=${encodeURIComponent(activeProject.id)}`) {
      return {
        status: 'valid',
        snapshot: createProjectTemplateSnapshot('youth-ai-course', '2026-07-01T00:00:00.000Z'),
      };
    }
    if (path === '/api/opencode/config-summary') {
      return {
        model: 'openai/gpt-5',
        smallModel: 'openai/gpt-5-mini',
        providerIds: ['openai'],
        providerCount: 1,
      };
    }
    if (path === '/api/opencode/skills') {
      return {
        skills: [{
          name: 'youth-ai-product-course',
          description: 'Use when guiding youth AI product course work.',
          location: 'D:/repo/packages/ui/.opencode/skills/youth-ai-product-course/SKILL.md',
          content: '# Course rules',
        }],
      };
    }
    if (path === '/api/opencode/sessions' && !init?.method) {
      return { sessions: [] };
    }
    if (path === '/api/opencode/sessions/status') {
      return { statuses: { ses_pm: { type: 'idle' } } };
    }
    if (path === '/api/opencode/sessions' && init?.method === 'POST') {
      const body = typeof init.body === 'string' ? JSON.parse(init.body) as { agent?: string; title?: string } : {};
      return {
        success: true,
        session: {
          id: 'ses_pm',
          agent: body.agent ?? 'pm',
          title: '1. 想法与计划 - 项目经理对话',
          updatedAt: '2026-05-12T08:30:00.000Z',
        },
      };
    }
    if (path === '/api/opencode/sessions/ses_pm/messages' && init?.method === 'POST') {
      return { success: true };
    }
    throw new Error(`Unexpected path ${path}`);
  });
}

describe('NiTu character scene partner profiles', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    hostApiFetchMock.mockReset();
    projectTemplateReadOverrideMock.mockReset();
    window.localStorage.clear();
    useAuthStore.setState({
      initialized: true,
      loading: false,
      error: null,
      accessToken: null,
      refreshToken: null,
      tokenType: null,
      expiresAt: null,
      user: null,
    });
    useProjectTemplatesStore.setState({
      snapshotsByProjectId: {},
      templateErrorsByProjectId: {},
      loadStatusByProjectId: {},
      templatesByProjectId: {},
    });
    usePartnerProfilesStore.setState({ subagents: [], projectRuntimeConfigsByProjectId: {}, profilesById: {} });
    useProviderStore.setState({
      statuses: [],
      accounts: [],
      vendors: [],
      defaultAccountId: null,
      loading: false,
      error: null,
    });
    resetOpencodeStore();
  });

  it('starts with an empty partner list and an add button', () => {
    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    expect(screen.getByTestId('partner-empty-state')).toBeInTheDocument();
    expect(screen.getByTestId('partner-create-open')).toBeInTheDocument();
    expect(screen.queryByTestId('partner-profile-card-pm')).not.toBeInTheDocument();
  });

  it('keeps add partner drawer text readable on light surfaces', async () => {
    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('partner-create-open'));
    const drawer = await screen.findByRole('dialog');
    const nameInput = within(drawer).getByTestId('partner-create-name');
    const roleSelect = within(drawer).getByTestId('partner-create-template-role');

    expect(nameInput).toHaveClass('bg-white', 'text-[#26384D]', 'placeholder:text-[#68788B]');
    expect(roleSelect).toHaveClass('bg-white', 'text-[#26384D]');
    expect(within(drawer).getByTestId('partner-create-system-prompt-note')).toHaveClass(
      'bg-[#EEF3F7]',
      'text-[#26384D]',
    );

    fireEvent.change(roleSelect, { target: { value: 'custom' } });
    expect(within(drawer).getByTestId('partner-create-prompt')).toHaveClass(
      'bg-white',
      'text-[#26384D]',
      'placeholder:text-[#68788B]',
    );
  });

  it('shows the selected system template prompt while creating a partner', async () => {
    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('partner-create-open'));
    const drawer = await screen.findByRole('dialog');
    const roleSelect = within(drawer).getByTestId('partner-create-template-role');

    expect(within(drawer).getByTestId('partner-create-template-prompt')).toHaveTextContent(
      '讨论项目目标、核心用户、第一版范围、任务清单和验收标准。',
    );

    fireEvent.change(roleSelect, { target: { value: 'product' } });
    expect(within(drawer).getByTestId('partner-create-template-prompt')).toHaveTextContent(
      '基于项目计划定义页面、按钮、交互、边界状态和 Demo 交付说明',
    );

    fireEvent.change(roleSelect, { target: { value: 'custom' } });
    expect(within(drawer).queryByTestId('partner-create-template-prompt')).not.toBeInTheDocument();
    expect(within(drawer).getByTestId('partner-create-prompt')).toBeInTheDocument();
  });

  it('lists all built-in youth and mini-game partner role templates', async () => {
    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('partner-create-open'));
    const drawer = await screen.findByRole('dialog');
    const roleSelect = within(drawer).getByTestId('partner-create-template-role');
    const optionValues = within(roleSelect)
      .getAllByRole('option')
      .map((option) => option.getAttribute('value'));

    expect(optionValues).toEqual([
      'pm',
      'product',
      'designer',
      'dev',
      'marketing',
      'deploy',
      'gameplay',
      'assets',
      'game-dev',
      'game-release',
      'game-showcase',
      'custom',
    ]);
  });

  it('creates an assets partner whose built-in prompt mentions existing asset search and license notes', async () => {
    hostApiFetchMock.mockImplementation(async (path: string) => {
      throw new Error(`Unexpected path ${path}`);
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('partner-create-open'));
    const createDrawer = await screen.findByRole('dialog');
    fireEvent.change(within(createDrawer).getByTestId('partner-create-name'), {
      target: { value: '素材小队长' },
    });
    fireEvent.change(within(createDrawer).getByTestId('partner-create-template-role'), {
      target: { value: 'assets' },
    });
    fireEvent.click(within(createDrawer).getByRole('button', { name: '创建伙伴' }));

    const card = await screen.findByTestId(/^partner-subagent-card-/);
    fireEvent.click(card);

    const profileDrawer = await screen.findByTestId(/^partner-profile-editor-/);
    expect(profileDrawer).toHaveTextContent('搜索现有游戏素材');
    expect(profileDrawer).toHaveTextContent('来源、授权、项目内路径');
    expect(profileDrawer).toHaveTextContent('ASSET_PLAN.md');
  });

  it('creates a built-in template runtime subagent from Partner Square', async () => {
    useOpencodeStore.setState({
      activeProject,
      projects: [activeProject],
    });
    hostApiFetchMock.mockResolvedValueOnce({
      success: true,
      subagent: {
        id: 'nitu-product-test',
        displayName: '我的产品伙伴',
        templateRoleId: 'product',
        filePath: 'D:/repo/.opencode/agent/nitu-product-test.md',
        createdAt: '2026-06-29T00:00:00.000Z',
        updatedAt: '2026-06-29T00:00:00.000Z',
      },
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('partner-create-open'));
    const drawer = await screen.findByRole('dialog');
    fireEvent.change(within(drawer).getByTestId('partner-create-name'), {
      target: { value: '我的产品伙伴' },
    });
    expect(within(drawer).queryByTestId('partner-create-course-role')).not.toBeInTheDocument();
    fireEvent.change(within(drawer).getByTestId('partner-create-template-role'), {
      target: { value: 'product' },
    });
    fireEvent.click(within(drawer).getByRole('button', { name: '创建伙伴' }));

    await waitFor(() => {
      expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/agents', {
        method: 'POST',
        body: JSON.stringify({
          displayName: '我的产品伙伴',
          templateRoleId: 'product',
        }),
      });
    });
    expect(await screen.findByTestId('partner-subagent-card-nitu-product-test')).toHaveTextContent('我的产品伙伴');
    expect(screen.getByTestId('partner-subagent-card-nitu-product-test')).toHaveTextContent('nitu-product-test');
    expect(usePartnerProfilesStore.getState().subagents[0].filePath).toBeUndefined();
    expect((usePartnerProfilesStore.getState() as any).projectRuntimeConfigsByProjectId[activeProject.id]['nitu-product-test']).toMatchObject({
      filePath: 'D:/repo/.opencode/agent/nitu-product-test.md',
    });
  });

  it('creates a local partner before any project is selected', async () => {
    hostApiFetchMock.mockImplementation(async (path: string) => {
      throw new Error(`Unexpected path ${path}`);
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('partner-create-open'));
    const drawer = await screen.findByRole('dialog');
    fireEvent.change(within(drawer).getByTestId('partner-create-name'), {
      target: { value: '小黄毛超级大冒险' },
    });
    fireEvent.change(within(drawer).getByTestId('partner-create-template-role'), {
      target: { value: 'pm' },
    });
    fireEvent.click(within(drawer).getByRole('button', { name: '创建伙伴' }));

    await waitFor(() => {
      expect(usePartnerProfilesStore.getState().subagents).toHaveLength(1);
    });
    const created = usePartnerProfilesStore.getState().subagents[0];
    expect(created).toMatchObject({
      displayName: '小黄毛超级大冒险',
      templateRoleId: 'pm',
    });
    expect(created.id).toMatch(/^nitu-pm-/);
    expect(created.filePath).toBeUndefined();
    expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/opencode/agents')).toBe(false);
    expect(screen.getAllByText('小黄毛超级大冒险').length).toBeGreaterThan(0);
  });

  it('shows created partners in the new project agent selector', async () => {
    usePartnerProfilesStore.setState({
      subagents: [{
        id: 'nitu-pm-test',
        displayName: '小计划官',
        templateRoleId: 'pm',
        filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
        createdAt: '2026-06-29T00:00:00.000Z',
        updatedAt: '2026-06-29T00:00:00.000Z',
      }],
      profilesById: {},
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    act(() => {
      window.dispatchEvent(new Event('nitu:create-project'));
    });
    const sheet = await screen.findByRole('dialog');
    const readyPmOption = within(sheet).getAllByText(/^小计划官$/)[0].closest('button');

    expect(readyPmOption).not.toBeDisabled();
    expect(readyPmOption).toHaveTextContent('可用');
    expect(within(sheet).queryByText(/^项目经理$/)).not.toBeInTheDocument();
  });

  it('uses Makelore as the character scene workspace brand', () => {
    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    expect(screen.getByText('Makelore')).toBeInTheDocument();
    expect(screen.queryByText(/NITU/)).not.toBeInTheDocument();
  });

  it('does not show the growth path section in the character scene menu', () => {
    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    expect(screen.queryByText('成长之路')).not.toBeInTheDocument();
    expect(screen.queryByText(/不影响主要操作/)).not.toBeInTheDocument();
  });

  it('shows imported Works Square user models in the LLM model drawer', async () => {
    useProviderStore.setState({
      accounts: [{
        id: 'niancode-user-models',
        vendorId: 'custom',
        label: 'Makelore Models',
        authMode: 'api_key',
        baseUrl: 'https://square.example/v1',
        model: 'qwen3.6-plus',
        fallbackModels: ['deepseek-chat', 'gpt-4.1-mini'],
        enabled: true,
        isDefault: true,
        createdAt: '2026-07-02T00:00:00.000Z',
        updatedAt: '2026-07-02T00:00:00.000Z',
      } as ProviderAccount],
      statuses: [{
        id: 'niancode-user-models',
        type: 'custom',
        name: 'Makelore Models',
        enabled: true,
        createdAt: '2026-07-02T00:00:00.000Z',
        updatedAt: '2026-07-02T00:00:00.000Z',
        hasKey: true,
        keyMasked: 'sk-***',
      } as ProviderWithKeyInfo],
      defaultAccountId: 'niancode-user-models',
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    expect(screen.getByTestId('resource-card-models')).toHaveTextContent('已激活3个');

    fireEvent.click(screen.getByTestId('resource-card-models'));
    const drawer = await screen.findByRole('dialog');

    const multimodalCard = within(drawer).getByTestId('nitu-model-card-qwen3-6-plus');
    const textCard = within(drawer).getByTestId('nitu-model-card-deepseek-chat');
    expect(multimodalCard).toHaveTextContent('qwen3.6-plus');
    expect(multimodalCard).toHaveTextContent('多模态');
    expect(textCard).toHaveTextContent('deepseek-chat');
    expect(textCard).toHaveTextContent('文本');
    expect(within(drawer).getByTestId('nitu-model-card-gpt-4-1-mini')).toHaveTextContent('gpt-4.1-mini');
    expect(multimodalCard).toHaveTextContent('默认账号');
    expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/auth/me/model-config')).toBe(false);
  });

  it('lets users manually re-fetch remote model config from the LLM drawer', async () => {
    useAuthStore.setState({
      accessToken: 'access-token',
      refreshToken: 'refresh-token',
      expiresAt: Date.now() + 60_000,
    });
    const existingAccount = {
      id: 'niancode-user-models',
      vendorId: 'custom',
      label: 'Makelore Models',
      authMode: 'api_key',
      baseUrl: 'https://square.example/v1',
      model: 'gpt-old-mini',
      fallbackModels: [],
      enabled: true,
      isDefault: true,
      createdAt: '2026-07-02T00:00:00.000Z',
      updatedAt: '2026-07-02T00:00:00.000Z',
    } as ProviderAccount;
    const refreshedAccount = {
      ...existingAccount,
      model: 'gpt-4.1-mini',
      fallbackModels: ['gpt-4o-mini', 'qwen3-coder'],
      updatedAt: '2026-07-07T00:00:00.000Z',
    } as ProviderAccount;

    useProviderStore.setState({
      accounts: [existingAccount],
      statuses: [{
        id: 'niancode-user-models',
        type: 'custom',
        name: 'Makelore Models',
        enabled: true,
        createdAt: '2026-07-02T00:00:00.000Z',
        updatedAt: '2026-07-02T00:00:00.000Z',
        hasKey: true,
        keyMasked: 'sk-***',
      } as ProviderWithKeyInfo],
      defaultAccountId: 'niancode-user-models',
    });

    hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
      if (path === '/api/provider-accounts/import-user-model-config' && init?.method === 'POST') {
        return {
          success: true,
          account: refreshedAccount,
          importedModels: ['gpt-4.1-mini', 'gpt-4o-mini', 'qwen3-coder'],
        };
      }
      if (path === '/api/provider-accounts') {
        return [refreshedAccount];
      }
      if (path === '/api/provider-accounts/key-info') {
        return [{ accountId: 'niancode-user-models', hasKey: true, keyMasked: 'sk-***' }];
      }
      if (path === '/api/provider-vendors') {
        return [];
      }
      if (path === '/api/provider-accounts/default') {
        return { accountId: 'niancode-user-models' };
      }
      throw new Error(`Unexpected path ${path}`);
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('resource-card-models'));
    const drawer = await screen.findByRole('dialog');

    fireEvent.click(within(drawer).getByRole('button', { name: '重新拉取远端模型配置' }));

    await waitFor(() => {
      expect(hostApiFetchMock).toHaveBeenCalledWith('/api/provider-accounts/import-user-model-config', {
        method: 'POST',
        body: JSON.stringify({ accessToken: 'access-token' }),
      });
    });
    await waitFor(() => {
      expect(within(drawer).getByTestId('nitu-model-card-gpt-4-1-mini')).toHaveTextContent('gpt-4.1-mini');
    });
    expect(within(drawer).getByTestId('nitu-model-card-gpt-4o-mini')).toHaveTextContent('gpt-4o-mini');
    expect(within(drawer).getByTestId('nitu-model-card-qwen3-coder')).toHaveTextContent('qwen3-coder');
  });

  it('syncs current user models when entering the LLM drawer before provider settings loaded', async () => {
    useAuthStore.setState({
      accessToken: 'access-token',
      expiresAt: Date.now() + 60_000,
    });
    const importedAccount = {
      id: 'niancode-user-models',
      vendorId: 'custom',
      label: 'Makelore Models',
      authMode: 'api_key',
      baseUrl: 'https://square.example/v1',
      model: 'gpt-4.1-mini',
      fallbackModels: ['gpt-4o-mini', 'qwen3-coder'],
      enabled: true,
      isDefault: true,
      createdAt: '2026-07-02T00:00:00.000Z',
      updatedAt: '2026-07-02T00:00:00.000Z',
    } as ProviderAccount;
    let providerAccountsRequests = 0;
    hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
      if (path === '/api/provider-accounts') {
        providerAccountsRequests += 1;
        return providerAccountsRequests === 1 ? [] : [importedAccount];
      }
      if (path === '/api/provider-accounts/key-info') {
        return providerAccountsRequests <= 1
          ? []
          : [{ accountId: 'niancode-user-models', hasKey: true, keyMasked: 'sk-***' }];
      }
      if (path === '/api/provider-vendors') {
        return [];
      }
      if (path === '/api/provider-accounts/default') {
        return { accountId: providerAccountsRequests <= 1 ? null : 'niancode-user-models' };
      }
      if (path === '/api/provider-accounts/import-user-model-config' && init?.method === 'POST') {
        return {
          success: true,
          account: importedAccount,
          importedModels: ['gpt-4.1-mini', 'gpt-4o-mini', 'qwen3-coder'],
        };
      }
      throw new Error(`Unexpected path ${path}`);
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('resource-card-models'));

    await waitFor(() => {
      expect(hostApiFetchMock).toHaveBeenCalledWith('/api/provider-accounts/import-user-model-config', {
        method: 'POST',
        body: JSON.stringify({ accessToken: 'access-token' }),
      });
    });
    await waitFor(() => {
      expect(screen.getByTestId('resource-card-models')).toHaveTextContent('已激活3个');
    });

    const drawer = await screen.findByRole('dialog');

    expect(within(drawer).getByTestId('nitu-model-card-gpt-4-1-mini')).toHaveTextContent('gpt-4.1-mini');
    expect(within(drawer).getByTestId('nitu-model-card-gpt-4o-mini')).toHaveTextContent('gpt-4o-mini');
    expect(within(drawer).getByTestId('nitu-model-card-qwen3-coder')).toHaveTextContent('qwen3-coder');
    expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/auth/me/model-config')).toBe(false);
  });

  it('shows a compact name and purpose list in the Skill library drawer', async () => {
    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('resource-card-skills'));
    const drawer = await screen.findByRole('dialog');

    expect(within(drawer).getByTestId('skill-library-card-youth-ai-product-course')).toBeInTheDocument();
    expect(getSkillCountLabel(drawer, 12)).toBeInTheDocument();
    expect(within(drawer).getByTestId('skill-library-card-youth-plain-language')).toHaveTextContent('青少年通俗表达');
    expect(within(drawer).getByTestId('skill-library-card-youth-plain-language')).not.toHaveTextContent('youth-plain-language');
    expect(within(drawer).getByTestId('skill-library-card-youth-ai-product-course')).toHaveTextContent('青少年人工智能产品课程');
    expect(within(drawer).getByTestId('skill-library-card-youth-ai-product-course')).toHaveTextContent('统一学生沟通方式');
    expect(within(drawer).getByTestId('skill-library-card-pm-project-plan')).toHaveTextContent('通用软件项目规划');
    expect(within(drawer).queryByTestId('skill-library-card-course-stage-review')).not.toBeInTheDocument();
    expect(within(drawer).getByTestId('skill-library-card-nianxxgame-skill')).toHaveTextContent('游戏开发');

    const pmSkill = within(drawer).getByTestId('skill-library-card-pm-project-plan');
    expect(pmSkill).toHaveTextContent('把软件想法整理成目标');
    expect(pmSkill).not.toHaveTextContent('pm-project-plan');
    expect(pmSkill).not.toHaveTextContent('触发场景');
    expect(pmSkill).not.toHaveTextContent('核心指令');
    expect(pmSkill).not.toHaveTextContent('输入上下文');
    expect(pmSkill).not.toHaveTextContent('输出产物');
    expect(pmSkill).not.toHaveTextContent('项目计划.md');
    expect(pmSkill).not.toHaveTextContent('保护规则');
  });

  it('keeps the course skill list visible when opencode returns no installed skills yet', async () => {
    let resolveSkills: ((value: { skills: unknown[] }) => void) | null = null;
    useOpencodeStore.setState({
      status: { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' },
      activeProject,
      projects: [activeProject],
    });
    hostApiFetchMock.mockImplementation(async (path: string) => {
      if (path === '/api/opencode/skills') {
        return await new Promise((resolve) => {
          resolveSkills = resolve as (value: { skills: unknown[] }) => void;
        });
      }
      throw new Error(`Unexpected path ${path}`);
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('resource-card-skills'));
    const drawer = await screen.findByRole('dialog');
    await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/skills'));

    await act(async () => {
      resolveSkills?.({ skills: [] });
    });

    expect(getSkillCountLabel(drawer, 12)).toBeInTheDocument();
    expect(within(drawer).getByTestId('skill-library-card-youth-ai-product-course')).toBeInTheDocument();
    expect(within(drawer).getByTestId('skill-library-card-pm-project-plan')).toBeInTheDocument();
  });

  it('opens the knowledge drawer and handles knowledge file actions', async () => {
    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('resource-card-knowledge'));
    const drawer = await screen.findByRole('dialog');

    expect(within(drawer).getByTestId('knowledge-file-kid-html')).toBeInTheDocument();
    expect(within(drawer).getByRole('button', { name: '上传知识文件' })).toBeInTheDocument();
    expect(within(drawer).getByTestId('knowledge-file-kid-html')).toHaveTextContent('HTML入门小卡片.md');

    const fileInput = within(drawer).getByTestId('knowledge-file-input') as HTMLInputElement;
    const inputClickSpy = vi.spyOn(fileInput, 'click');
    fireEvent.click(within(drawer).getByRole('button', { name: '上传知识文件' }));
    expect(inputClickSpy).toHaveBeenCalledTimes(1);

    fireEvent.change(fileInput, {
      target: {
        files: [new File(['# classroom notes'], 'classroom-notes.md', { type: 'text/markdown' })],
      },
    });
    expect(within(drawer).getByText('classroom-notes.md')).toBeInTheDocument();
    expect(within(drawer).getByText('已上传知识文件：classroom-notes.md')).toBeInTheDocument();

    const dropZone = within(drawer).getByTestId('knowledge-drop-zone');
    fireEvent.dragOver(dropZone, {
      dataTransfer: {
        files: [new File(['travel'], 'travel-guide.pdf', { type: 'application/pdf' })],
      },
    });
    fireEvent.drop(dropZone, {
      dataTransfer: {
        files: [new File(['travel'], 'travel-guide.pdf', { type: 'application/pdf' })],
      },
    });
    expect(within(drawer).getByText('travel-guide.pdf')).toBeInTheDocument();
    expect(within(drawer).getByText('已上传知识文件：travel-guide.pdf')).toBeInTheDocument();

    fireEvent.click(within(drawer).getByRole('button', { name: '删除 HTML入门小卡片.md' }));
    expect(within(drawer).queryByTestId('knowledge-file-kid-html')).not.toBeInTheDocument();
    expect(within(drawer).getByText('已从本地知识库移除：HTML入门小卡片.md')).toBeInTheDocument();
  });

  it('edits nickname, description, and avatar from the partner config drawer', async () => {
    usePartnerProfilesStore.setState({
      subagents: [{
        id: 'nitu-pm-test',
        displayName: '我的 PM',
        templateRoleId: 'pm',
        filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
        createdAt: '2026-06-29T00:00:00.000Z',
        updatedAt: '2026-06-29T00:00:00.000Z',
      }],
      profilesById: {},
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('partner-subagent-card-nitu-pm-test'));
    const drawer = await screen.findByTestId('partner-profile-editor-nitu-pm-test');

    fireEvent.change(within(drawer).getByLabelText('伙伴昵称'), {
      target: { value: '小计划官' },
    });
    fireEvent.change(within(drawer).getByLabelText('伙伴描述'), {
      target: { value: 'Gentle planning helper.' },
    });
    fireEvent.click(within(drawer).getByTestId('partner-avatar-tone-nitu-pm-test-1'));
    fireEvent.click(within(drawer).getByRole('button', { name: '保存配置' }));

    await waitFor(() => {
      const card = screen.getByTestId('partner-subagent-card-nitu-pm-test');
      expect(card).toHaveTextContent('小计划官');
      expect(card).toHaveTextContent('Gentle planning helper.');
      expect(card).toHaveTextContent('项目经理 · 思路整理');
    });
  });

  it('edits runtime subagent skill bindings from the partner config drawer', async () => {
    useOpencodeStore.setState({
      activeProject,
      projects: [activeProject],
    });
    usePartnerProfilesStore.setState({
      subagents: [{
        id: 'nitu-pm-test',
        displayName: '我的 PM',
        templateRoleId: 'pm',
        skillIds: ['youth-ai-product-course', 'pm-project-plan'],
        filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
        createdAt: '2026-06-29T00:00:00.000Z',
        updatedAt: '2026-06-29T00:00:00.000Z',
      }],
      projectRuntimeConfigsByProjectId: {
        [activeProject.id]: {
          'nitu-pm-test': {
            filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
            skillIds: ['youth-ai-product-course', 'pm-project-plan'],
            updatedAt: '2026-06-29T00:00:00.000Z',
          },
        },
      },
      profilesById: {},
    });
    hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
      if (path === '/api/opencode/agents/nitu-pm-test' && init?.method === 'PATCH') {
        return {
          success: true,
          subagent: {
            id: 'nitu-pm-test',
            displayName: '我的 PM',
            templateRoleId: 'pm',
            skillIds: ['youth-ai-product-course', 'ui-ux-course-quality'],
            filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
            createdAt: '2026-06-29T00:00:00.000Z',
            updatedAt: '2026-07-02T00:00:00.000Z',
          },
        };
      }
      throw new Error(`Unexpected path ${path}`);
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('partner-subagent-card-nitu-pm-test'));
    const drawer = await screen.findByTestId('partner-profile-editor-nitu-pm-test');

    const pmSkill = within(drawer).getByLabelText('绑定技能：通用软件项目规划') as HTMLInputElement;
    const uiSkill = within(drawer).getByLabelText('绑定技能：界面体验质检') as HTMLInputElement;
    expect(pmSkill.checked).toBe(true);
    expect(uiSkill.checked).toBe(false);

    fireEvent.click(pmSkill);
    fireEvent.click(uiSkill);
    fireEvent.click(within(drawer).getByRole('button', { name: '保存配置' }));

    await waitFor(() => {
      expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/agents/nitu-pm-test', {
        method: 'PATCH',
        body: JSON.stringify({
          displayName: '我的 PM',
          templateRoleId: 'pm',
          skillIds: ['youth-ai-product-course', 'ui-ux-course-quality'],
        }),
      });
    });
    expect(usePartnerProfilesStore.getState().subagents[0].skillIds).toEqual([
      'youth-ai-product-course',
      'ui-ux-course-quality',
    ]);
    expect((usePartnerProfilesStore.getState() as any).projectRuntimeConfigsByProjectId[activeProject.id]['nitu-pm-test']).toMatchObject({
      filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
      skillIds: ['youth-ai-product-course', 'ui-ux-course-quality'],
    });
    expect(screen.getByTestId('partner-subagent-card-nitu-pm-test')).toHaveTextContent('2 个技能');
  });

  it('locks the game Skill for a mini-game template and keeps it in the project runtime save', async () => {
    const gameTemplate = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z');
    useProjectTemplatesStore.getState().setProjectTemplateSnapshot(activeProject.id, gameTemplate);
    useOpencodeStore.setState({
      activeProject,
      projects: [activeProject],
    });
    usePartnerProfilesStore.setState({
      subagents: [{
        id: 'nitu-game-dev-test',
        displayName: '我的游戏开发师',
        templateRoleId: 'game-dev',
        skillIds: ['dev-build-test', 'ui-ux-course-quality'],
        filePath: 'D:/repo/.opencode/agent/nitu-game-dev-test.md',
        createdAt: '2026-07-01T00:00:00.000Z',
        updatedAt: '2026-07-01T00:00:00.000Z',
      }],
      projectRuntimeConfigsByProjectId: {
        [activeProject.id]: {
          'nitu-game-dev-test': {
            filePath: 'D:/repo/.opencode/agent/nitu-game-dev-test.md',
            skillIds: ['dev-build-test', 'ui-ux-course-quality'],
            updatedAt: '2026-07-01T00:00:00.000Z',
          },
        },
      },
      profilesById: {},
    });
    hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
      if (path === '/api/opencode/skills') return { skills: [] };
      if (path === '/api/opencode/agents/nitu-game-dev-test' && init?.method === 'PATCH') {
        const body = JSON.parse(String(init.body)) as { skillIds: string[] };
        return {
          success: true,
          subagent: {
            id: 'nitu-game-dev-test',
            displayName: '我的游戏开发师',
            templateRoleId: 'game-dev',
            skillIds: [...body.skillIds, 'nianxxgame-skill'],
            filePath: 'D:/repo/.opencode/agent/nitu-game-dev-test.md',
            createdAt: '2026-07-01T00:00:00.000Z',
            updatedAt: '2026-07-01T00:01:00.000Z',
          },
        };
      }
      throw new Error(`Unexpected path ${path}`);
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('partner-subagent-card-nitu-game-dev-test'));
    const drawer = await screen.findByTestId('partner-profile-editor-nitu-game-dev-test');
    const gameSkill = await within(drawer).findByLabelText('绑定技能：网页游戏开发') as HTMLInputElement;

    expect(gameSkill.checked).toBe(true);
    expect(gameSkill.disabled).toBe(true);
    expect(within(drawer).getByText('游戏模板必需')).toBeInTheDocument();

    fireEvent.click(within(drawer).getByRole('button', { name: '保存配置' }));

    await waitFor(() => {
      const saveCall = hostApiFetchMock.mock.calls.find(([path, init]) => (
        path === '/api/opencode/agents/nitu-game-dev-test' && (init as RequestInit | undefined)?.method === 'PATCH'
      ));
      expect(saveCall).toBeDefined();
      const body = JSON.parse(String((saveCall?.[1] as RequestInit).body)) as { skillIds: string[] };
      expect(body.skillIds).toEqual([
        'dev-build-test',
        'ui-ux-course-quality',
      ]);
    });
    expect(usePartnerProfilesStore.getState().subagents[0]?.skillIds).not.toContain('nianxxgame-skill');
    expect(
      usePartnerProfilesStore.getState().projectRuntimeConfigsByProjectId[activeProject.id]?.['nitu-game-dev-test']?.skillIds,
    ).toContain('nianxxgame-skill');
  });

  it('leaves the game Skill manually selectable for a standard project template', async () => {
    const standardTemplate = createProjectTemplateSnapshot('standard-dev', '2026-07-01T00:00:00.000Z');
    useProjectTemplatesStore.getState().setProjectTemplateSnapshot(activeProject.id, standardTemplate);
    useOpencodeStore.setState({
      activeProject,
      projects: [activeProject],
    });
    usePartnerProfilesStore.setState({
      subagents: [{
        id: 'nitu-manual-game-dev-test',
        displayName: '手动游戏开发师',
        templateRoleId: 'game-dev',
        skillIds: ['dev-build-test'],
        filePath: 'D:/repo/.opencode/agent/nitu-manual-game-dev-test.md',
        createdAt: '2026-07-01T00:00:00.000Z',
        updatedAt: '2026-07-01T00:00:00.000Z',
      }],
      projectRuntimeConfigsByProjectId: {
        [activeProject.id]: {
          'nitu-manual-game-dev-test': {
            filePath: 'D:/repo/.opencode/agent/nitu-manual-game-dev-test.md',
            skillIds: ['dev-build-test'],
            updatedAt: '2026-07-01T00:00:00.000Z',
          },
        },
      },
      profilesById: {},
    });
    hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
      if (path === '/api/opencode/skills') return { skills: [] };
      if (path === '/api/opencode/agents/nitu-manual-game-dev-test' && init?.method === 'PATCH') {
        const body = JSON.parse(String(init.body)) as { skillIds: string[] };
        return {
          success: true,
          subagent: {
            id: 'nitu-manual-game-dev-test',
            displayName: '手动游戏开发师',
            templateRoleId: 'game-dev',
            skillIds: body.skillIds,
            filePath: 'D:/repo/.opencode/agent/nitu-manual-game-dev-test.md',
            createdAt: '2026-07-01T00:00:00.000Z',
            updatedAt: '2026-07-01T00:01:00.000Z',
          },
        };
      }
      throw new Error(`Unexpected path ${path}`);
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('partner-subagent-card-nitu-manual-game-dev-test'));
    const drawer = await screen.findByTestId('partner-profile-editor-nitu-manual-game-dev-test');
    const gameSkill = await within(drawer).findByLabelText('绑定技能：网页游戏开发') as HTMLInputElement;

    expect(gameSkill.checked).toBe(false);
    expect(gameSkill.disabled).toBe(false);
    expect(within(drawer).queryByText('游戏模板必需')).not.toBeInTheDocument();

    fireEvent.click(gameSkill);
    expect(gameSkill.checked).toBe(true);
    fireEvent.click(within(drawer).getByRole('button', { name: '保存配置' }));

    await waitFor(() => {
      const saveCall = hostApiFetchMock.mock.calls.find(([path, init]) => (
        path === '/api/opencode/agents/nitu-manual-game-dev-test' && (init as RequestInit | undefined)?.method === 'PATCH'
      ));
      expect(saveCall).toBeDefined();
      const body = JSON.parse(String((saveCall?.[1] as RequestInit).body)) as { skillIds: string[] };
      expect(body.skillIds).toContain('nianxxgame-skill');
    });
  });

  it('shows installed opencode skills and allows binding them to a partner', async () => {
    useOpencodeStore.setState({
      status: { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' },
      activeProject,
      projects: [activeProject],
    });
    usePartnerProfilesStore.setState({
      subagents: [{
        id: 'nitu-pm-test',
        displayName: '我的 PM',
        templateRoleId: 'pm',
        skillIds: ['youth-ai-product-course'],
        filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
        createdAt: '2026-06-29T00:00:00.000Z',
        updatedAt: '2026-06-29T00:00:00.000Z',
      }],
      projectRuntimeConfigsByProjectId: {
        [activeProject.id]: {
          'nitu-pm-test': {
            filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
            skillIds: ['youth-ai-product-course'],
            updatedAt: '2026-06-29T00:00:00.000Z',
          },
        },
      },
      profilesById: {},
    });
    hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
      if (path === '/api/opencode/skills') {
        return {
          skills: [
            {
              name: 'repo-audit',
              description: 'Use when auditing repository structure.',
              location: 'D:/repo/packages/ui/.opencode/skills/repo-audit/SKILL.md',
              content: '# Repo audit',
            },
            {
              name: 'youth-ai-product-course',
              description: 'Use when guiding youth AI product course work.',
              location: 'D:/repo/packages/ui/.opencode/skills/youth-ai-product-course/SKILL.md',
              content: '# Course rules',
            },
          ],
        };
      }
      if (path === '/api/opencode/agents/nitu-pm-test' && init?.method === 'PATCH') {
        return {
          success: true,
          subagent: {
            id: 'nitu-pm-test',
            displayName: '我的 PM',
            templateRoleId: 'pm',
            skillIds: ['youth-ai-product-course', 'repo-audit'],
            filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
            createdAt: '2026-06-29T00:00:00.000Z',
            updatedAt: '2026-07-05T00:00:00.000Z',
          },
        };
      }
      throw new Error(`Unexpected path ${path}`);
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByText('技能库'));
    expect(await screen.findByTestId('skill-library-card-repo-audit')).toHaveTextContent('自定义技能');
    expect(screen.getByTestId('skill-library-card-repo-audit')).not.toHaveTextContent('repo-audit');
    expect(screen.getByTestId('skill-library-card-youth-ai-product-course')).toHaveTextContent('青少年人工智能产品课程');
    expect(screen.getByTestId('skill-library-card-youth-ai-product-course')).not.toHaveTextContent('youth-ai-product-course');

    fireEvent.click(screen.getByTestId('partner-subagent-card-nitu-pm-test'));
    const drawer = await screen.findByTestId('partner-profile-editor-nitu-pm-test');
    const repoAuditSkill = await within(drawer).findByLabelText('绑定技能：自定义技能') as HTMLInputElement;
    expect(repoAuditSkill.checked).toBe(false);

    fireEvent.click(repoAuditSkill);
    fireEvent.click(within(drawer).getByRole('button', { name: '保存配置' }));

    await waitFor(() => {
      expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/agents/nitu-pm-test', {
        method: 'PATCH',
        body: JSON.stringify({
          displayName: '我的 PM',
          templateRoleId: 'pm',
          skillIds: ['youth-ai-product-course', 'repo-audit'],
        }),
      });
    });
  });

  it('refreshes the active project runtime subagent when saving unchanged skill bindings', async () => {
    useOpencodeStore.setState({
      activeProject,
      projects: [activeProject],
    });
    usePartnerProfilesStore.setState({
      subagents: [{
        id: 'nitu-pm-test',
        displayName: '我的 PM',
        templateRoleId: 'pm',
        skillIds: ['youth-ai-product-course', 'pm-project-plan'],
        filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
        createdAt: '2026-06-29T00:00:00.000Z',
        updatedAt: '2026-06-29T00:00:00.000Z',
      }],
      projectRuntimeConfigsByProjectId: {
        [activeProject.id]: {
          'nitu-pm-test': {
            filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
            skillIds: ['youth-ai-product-course', 'pm-project-plan'],
            updatedAt: '2026-06-29T00:00:00.000Z',
          },
        },
      },
      profilesById: {},
    });
    hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
      if (path === '/api/opencode/agents/nitu-pm-test' && init?.method === 'PATCH') {
        return {
          success: true,
          subagent: {
            id: 'nitu-pm-test',
            displayName: '我的 PM',
            templateRoleId: 'pm',
            skillIds: ['youth-ai-product-course', 'pm-project-plan'],
            filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
            createdAt: '2026-06-29T00:00:00.000Z',
            updatedAt: '2026-07-04T00:00:00.000Z',
          },
        };
      }
      throw new Error(`Unexpected path ${path}`);
    });

    render(
      <MemoryRouter>
        <CharacterScene />
      </MemoryRouter>,
    );

    fireEvent.click(screen.getByTestId('partner-subagent-card-nitu-pm-test'));
    const drawer = await screen.findByTestId('partner-profile-editor-nitu-pm-test');
    fireEvent.click(within(drawer).getByRole('button', { name: '保存配置' }));

    await waitFor(() => {
      expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/agents/nitu-pm-test', {
        method: 'PATCH',
        body: JSON.stringify({
          displayName: '我的 PM',
          templateRoleId: 'pm',
          skillIds: ['youth-ai-product-course', 'pm-project-plan'],
        }),
      });
    });
    expect((usePartnerProfilesStore.getState() as any).projectRuntimeConfigsByProjectId[activeProject.id]['nitu-pm-test']).toMatchObject({
      filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
      skillIds: ['youth-ai-product-course', 'pm-project-plan'],
      updatedAt: '2026-07-04T00:00:00.000Z',
    });
  });

  it('shows edited partner names in project selectors without implicitly binding course roles', async () => {
    usePartnerProfilesStore.setState({
      subagents: [{
        id: 'nitu-pm-test',
        displayName: '小计划官',
        templateRoleId: 'pm',
        filePath: 'D:/repo/.opencode/agent/nitu-pm-test.md',
        createdAt: '2026-06-29T00:00:00.000Z',
        updatedAt: '2026-06-29T00:00:00.000Z',
      }],
      profilesById: {},
    });

    const scene = render(
      <MemoryRouter initialEntries={['/']}>
        <Routes>
          <Route path="/" element={<CharacterScene />} />
          <Route path="/chat" element={<ChatScene />} />
        </Routes>
      </MemoryRouter>,
    );

    act(() => {
      window.dispatchEvent(new Event('nitu:create-project'));
    });
    const sheet = await screen.findByRole('dialog');

    expect(within(sheet).getAllByText('小计划官').length).toBeGreaterThan(0);
    expect(within(sheet).queryByText(/^项目经理$/)).not.toBeInTheDocument();

    fireEvent.click(within(sheet).getByRole('button', { name: '取消' }));
    scene.unmount();

    render(
      <MemoryRouter>
        <ChatScene />
      </MemoryRouter>,
    );

    expect(await screen.findByText('当前Agent：项目经理 · 思路整理')).toBeInTheDocument();
    expect(screen.queryByText('当前Agent：小计划官 · 思路整理')).not.toBeInTheDocument();
  });

});
