Files
makelore/tests/unit/plugins-page.test.tsx

790 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useState } from 'react';
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Plugins, PluginsView } from '@/pages/Plugins';
import { useAuthStore } from '@/stores/auth';
import { codingPluginsStore } from '@/stores/coding-plugins';
import { codingWorkspaceStore } from '@/stores/coding-workspace';
import { devicePackageStore } from '@/stores/device-packages';
import { pluginMarketplaceStore } from '@/stores/plugin-marketplace';
import type {
PluginWorkspaceItem,
PluginWorkspaceProjection,
} from '@/pages/Plugins/plugin-workspace-model';
const initialAuthState = useAuthStore.getInitialState();
const initialCodingPluginsState = codingPluginsStore.getInitialState();
const initialCodingWorkspaceState = codingWorkspaceStore.getInitialState();
const initialDevicePackageState = devicePackageStore.getInitialState();
const initialMarketplaceState = pluginMarketplaceStore.getInitialState();
afterEach(() => {
vi.restoreAllMocks();
act(() => {
useAuthStore.setState(initialAuthState, true);
codingPluginsStore.setState(initialCodingPluginsState, true);
codingWorkspaceStore.setState(initialCodingWorkspaceState, true);
devicePackageStore.setState(initialDevicePackageState, true);
pluginMarketplaceStore.setState(initialMarketplaceState, true);
});
});
const projectPlugin = {
id: 'makelore.data-service',
version: '1.0.0',
contractVersion: 1,
requiresBackend: true,
displayName: '开发数据服务',
description: '为当前项目提供 JSON 数据。',
enabled: true,
state: 'configuration_required' as const,
backend: { status: 'unconfigured' as const },
skills: [{ id: 'data-service', assignedAgentIds: ['agent-a'] }],
capabilities: [{
id: 'data-service.documents',
operations: [{
id: 'put_document',
billing: { mode: 'included' as const, availability: 'available' as const, notice: 'Fixed quotas apply' },
tool: {
name: 'data_service_put_document',
label: '写入文档',
description: '写入项目数据',
mutation: 'write' as const,
permissions: ['project.data.write'],
},
}],
}],
settingsSurface: 'data-service',
};
const officialItem: PluginWorkspaceItem = {
key: 'official:makelore.data-service',
source: 'official',
pluginId: 'makelore.data-service',
packageId: null,
title: '开发数据服务',
summary: '为当前项目提供 JSON 数据。',
category: '系统',
tags: ['data'],
publisher: 'MakeLore',
version: '1.0.0',
delivery: 'system_included',
projectState: 'enabled',
projectPolicyStatus: 'current',
localEnabled: null,
assignedAgentIds: ['agent-a'],
assignedAgentNames: ['小明'],
billing: 'included',
updateAvailable: false,
unavailable: false,
stale: false,
suspended: false,
retired: false,
deviceReason: null,
commands: [
{ kind: 'disable_project', projectId: 'project-a', pluginId: 'makelore.data-service' },
{ kind: 'open_settings', projectId: 'project-a', pluginId: 'makelore.data-service' },
],
official: {
catalog: null,
library: null,
installation: null,
project: projectPlugin,
detail: {
pluginId: 'makelore.data-service',
title: '开发数据服务',
summary: '为当前项目提供 JSON 数据。',
category: '系统',
tags: ['data'],
providerDisplayName: 'MakeLore',
runtimeKind: 'bundled_typed',
runtimeStatus: 'enabled',
acquisition: 'system_included',
usageBilling: 'included',
includedOperationCount: 1,
meteredOperationCount: 0,
descriptionMarkdown: '详细的数据服务介绍。',
permissions: ['读取项目名称'],
operations: [],
stableVersion: '1.0.0',
betaVersion: null,
stableRelease: { releaseId: 'data-1', version: '1.0.0' },
betaRelease: null,
etag: 'data-1',
pricingVersionId: null,
stale: false,
fetchedAt: 1,
},
},
local: null,
};
const localItem: PluginWorkspaceItem = {
key: 'local:pi-web-search',
source: 'local',
pluginId: 'pi-web-search',
packageId: 'pi-web-search',
title: 'Pi Web Search',
summary: 'npm:pi-web-search',
category: null,
tags: ['search'],
publisher: null,
version: '1.2.3',
delivery: 'local_installed',
projectState: 'not_applicable',
projectPolicyStatus: null,
localEnabled: true,
assignedAgentIds: [],
assignedAgentNames: [],
billing: 'none',
updateAvailable: false,
unavailable: false,
stale: false,
suspended: false,
retired: false,
deviceReason: null,
commands: [
{ kind: 'disable_local', packageId: 'pi-web-search' },
{ kind: 'remove_local', packageId: 'pi-web-search' },
],
official: null,
local: {
schemaVersion: 1,
packageId: 'pi-web-search',
displayName: 'Pi Web Search',
resolvedVersion: '1.2.3',
source: { kind: 'npm', requested: 'npm:pi-web-search', resolved: 'npm:pi-web-search@1.2.3' },
kind: 'mixed',
skillEntries: [{ id: 'search', entryPath: 'skills/search/SKILL.md' }],
extensionEntries: ['extensions/search.ts'],
enabled: true,
confirmedExecutableCode: true,
installedAt: '2026-09-03T00:00:00Z',
},
};
function projection(selected: PluginWorkspaceItem | null = null): PluginWorkspaceProjection {
return {
items: [officialItem, localItem],
selected,
counts: { all: 2, available: 0, mine: 2, enabled: 2, update: 0, unavailable: 0 },
notices: [],
};
}
function props(selected: PluginWorkspaceItem | null = null) {
return {
projection: projection(selected),
activeProjectId: 'project-a',
activeProjectName: 'Project A',
sourceErrors: [],
sourceLoading: [],
dataService: null,
dataServicePending: {},
onSelect: vi.fn(),
onCommand: vi.fn(),
isCommandPending: vi.fn().mockReturnValue(false),
onConfigureDataService: vi.fn(),
onResetDataService: vi.fn(),
onRemoveDataServiceCollection: vi.fn(),
onRemoveDataServiceProject: vi.fn(),
};
}
function PluginsRouteHarness() {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<Plugins />
<div data-testid="plugins-location">{location.pathname}{location.search}</div>
<button type="button" onClick={() => navigate('/project-config/plugins')}></button>
<button
type="button"
onClick={() => navigate('/project-config/plugins?scope=illegal&source=remote&state=broken')}
></button>
</>
);
}
function preparePluginsRouteState() {
useAuthStore.setState({ user: null });
codingWorkspaceStore.setState({
activeProjectId: null,
activeProject: null,
config: null,
loadState: 'ready',
});
codingPluginsStore.setState({ projection: null, loadState: 'ready', error: null });
devicePackageStore.setState({
index: { schemaVersion: 1, generation: 1, packages: [] },
state: 'ready',
error: null,
});
pluginMarketplaceStore.setState({
accountKey: null,
catalog: null,
catalogState: 'ready',
catalogError: null,
library: null,
libraryState: 'idle',
libraryError: null,
installations: {},
details: {},
detailState: {},
});
vi.spyOn(pluginMarketplaceStore.getState(), 'loadCatalog').mockResolvedValue(undefined);
vi.spyOn(devicePackageStore.getState(), 'load').mockResolvedValue(undefined);
vi.spyOn(codingPluginsStore.getState(), 'load').mockResolvedValue(undefined);
}
describe('PluginsView', () => {
it('renders a direct Skill-like list in the project drawer without filters or refresh', () => {
render(<MemoryRouter><PluginsView {...props()} embedded /></MemoryRouter>);
expect(screen.getByTestId('plugins-page')).toBeVisible();
expect(screen.queryByRole('heading', { name: '插件', level: 1 })).not.toBeInTheDocument();
expect(screen.getByTestId('plugin-list')).toHaveClass('space-y-3');
expect(screen.queryByRole('button', { name: /刷新/ })).not.toBeInTheDocument();
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '查看开发数据服务详情' })).toBeVisible();
expect(screen.getByRole('button', { name: '开发数据服务:已添加到项目' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Pi Web Search已安装' })).toBeDisabled();
});
it('keeps the production URL and fallback notice aligned without exposing query controls', async () => {
preparePluginsRouteState();
render(
<MemoryRouter initialEntries={['/project-config/plugins?scope=project']}>
<PluginsRouteHarness />
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/project-config/plugins?scope=all&source=all&state=all',
));
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
const activeProject = {
id: 'project-a',
name: 'Project A',
createdAt: '2026-09-03T00:00:00Z',
updatedAt: '2026-09-03T00:00:00Z',
lastOpenedAt: '2026-09-03T00:00:00Z',
};
act(() => codingWorkspaceStore.setState({ activeProjectId: activeProject.id, activeProject }));
fireEvent.click(screen.getByRole('button', { name: '打开裸插件地址' }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/project-config/plugins?scope=project&source=all&state=all',
));
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '打开非法插件地址' }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/project-config/plugins?scope=project&source=all&state=all',
));
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
act(() => codingWorkspaceStore.setState({ activeProjectId: null, activeProject: null }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/project-config/plugins?scope=all&source=all&state=all',
));
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
});
it('keeps available rows visible when individual sources fail to load', async () => {
const activeProject = {
id: 'project-a',
name: 'Project A',
createdAt: '2026-09-03T00:00:00Z',
updatedAt: '2026-09-03T00:00:00Z',
lastOpenedAt: '2026-09-03T00:00:00Z',
};
useAuthStore.setState({ user: null });
codingWorkspaceStore.setState({
activeProjectId: activeProject.id,
activeProject,
config: null,
loadState: 'ready',
});
codingPluginsStore.setState({
projectId: activeProject.id,
projection: {
schemaVersion: 1,
project: { localProjectId: activeProject.id, durableProjectId: 'durable-a' },
policyStatus: 'current',
unknownPluginIds: ['makelore.retained'],
items: [projectPlugin],
},
loadState: 'error',
error: 'project refresh failed',
});
devicePackageStore.setState({
index: { schemaVersion: 1, generation: 8, packages: [localItem.local!] },
state: 'error',
error: 'device refresh failed',
});
pluginMarketplaceStore.setState({
accountKey: null,
catalog: null,
catalogState: 'ready',
catalogError: null,
library: null,
libraryState: 'idle',
libraryError: null,
installations: {},
details: {},
detailState: {},
});
vi.spyOn(pluginMarketplaceStore.getState(), 'loadCatalog').mockResolvedValue(undefined);
vi.spyOn(devicePackageStore.getState(), 'load').mockResolvedValue(undefined);
vi.spyOn(codingPluginsStore.getState(), 'load').mockResolvedValue(undefined);
render(<MemoryRouter initialEntries={['/project-config/plugins?scope=project&source=all&state=all']}><Plugins /></MemoryRouter>);
for (const name of ['开发数据服务', 'Pi Web Search', 'makelore.retained']) {
expect(await screen.findByRole('heading', { name })).toBeVisible();
}
expect(screen.getAllByRole('alert')).toHaveLength(2);
expect(screen.queryByText('缓存')).not.toBeInTheDocument();
});
it('attempts a rejected official detail load once per open and retries only after close and reopen', async () => {
preparePluginsRouteState();
pluginMarketplaceStore.setState({
catalog: {
items: [{
pluginId: 'makelore.notes',
title: 'Notes',
summary: 'Write project notes',
category: 'productivity',
tags: ['notes'],
providerDisplayName: 'MakeLore',
runtimeKind: 'skill_only',
runtimeStatus: 'enabled',
acquisition: 'free',
usageBilling: 'token_point',
includedOperationCount: 0,
meteredOperationCount: 1,
stableVersion: '2.0.0',
betaVersion: null,
}],
nextCursor: null,
total: 1,
catalogGeneration: 1,
etag: 'catalog-1',
pricingVersionId: 'pricing-1',
stale: false,
fetchedAt: 1,
},
});
let rejectFirstAttempt: (reason?: unknown) => void = () => undefined;
const firstAttempt = new Promise<void>((_resolve, reject) => {
rejectFirstAttempt = reject;
});
const loadDetail = vi.spyOn(pluginMarketplaceStore.getState(), 'loadDetail')
.mockImplementationOnce(async () => await firstAttempt)
.mockResolvedValue(undefined);
render(
<MemoryRouter initialEntries={[
'/project-config/plugins?scope=all&source=all&state=all&plugin=official%3Amakelore.notes',
]}>
<Plugins />
</MemoryRouter>,
);
await waitFor(() => expect(loadDetail).toHaveBeenCalledTimes(1));
await act(async () => {
pluginMarketplaceStore.setState((state) => ({
detailState: { ...state.detailState, 'makelore.notes': 'error' },
catalogError: 'detail offline',
}));
rejectFirstAttempt(new Error('detail offline'));
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(loadDetail).toHaveBeenCalledTimes(1);
expect(screen.getByRole('alert', { hidden: true })).toHaveTextContent('插件详情加载失败。 detail offline');
fireEvent.click(screen.getByRole('button', { name: '关闭插件详情' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Notes' })).not.toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: '查看Notes详情' }));
await waitFor(() => expect(loadDetail).toHaveBeenCalledTimes(2));
});
it('renders the capability-first list with details and contextual actions', () => {
const view = props();
view.sourceErrors = [
{ source: 'catalog', message: 'catalog offline' },
{ source: 'project', message: 'project unavailable' },
];
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
expect(screen.getByRole('heading', { name: '插件' })).toBeVisible();
expect(screen.getByText('为你的智能体添加插件,拓展更多能力。')).toBeVisible();
expect(screen.getByText('开发数据服务')).toBeVisible();
expect(screen.getByText('Pi Web Search')).toBeVisible();
expect(screen.getByRole('button', { name: '查看开发数据服务详情' })).toBeVisible();
expect(screen.getByRole('button', { name: '开发数据服务:已添加到项目' })).toBeDisabled();
expect(screen.getAllByRole('alert')).toHaveLength(2);
expect(screen.queryByRole('button', { name: /刷新/ })).not.toBeInTheDocument();
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
});
it('labels an unacquired code-owned bundled plugin as supplied by MakeLore instead of not downloaded', () => {
const scaffoldItem: PluginWorkspaceItem = {
...officialItem,
key: 'official:makelore.project-scaffold',
pluginId: 'makelore.project-scaffold',
title: 'MakeLore 项目脚手架',
delivery: 'not_acquired',
projectState: 'disabled',
assignedAgentIds: [],
assignedAgentNames: [],
commands: [],
official: {
catalog: null,
detail: null,
library: null,
installation: null,
project: {
...projectPlugin,
id: 'makelore.project-scaffold',
displayName: 'MakeLore 项目脚手架',
enabled: false,
state: 'disabled',
},
},
};
const view = props(scaffoldItem);
view.projection = {
items: [scaffoldItem],
selected: scaffoldItem,
counts: { all: 1, available: 0, mine: 0, enabled: 0, update: 0, unavailable: 0 },
notices: [],
};
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
expect(screen.getByRole('button', { name: 'MakeLore 项目脚手架:随应用提供', hidden: true })).toBeDisabled();
const dialog = screen.getByRole('dialog', { name: 'MakeLore 项目脚手架' });
expect(dialog).toHaveTextContent('此插件随应用提供,添加到项目后即可使用。');
expect(dialog).not.toHaveTextContent('未下载官方包');
expect(dialog).not.toHaveTextContent('生效范围');
});
it('shows one dialog detail surface, confirms destructive commands, and embeds Data Service settings', () => {
const view = props(officialItem);
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
const dialog = screen.getByRole('dialog', { name: '开发数据服务' });
expect(dialog).toHaveTextContent('详细的数据服务介绍。');
expect(dialog).toHaveTextContent('写入文档');
expect(dialog).toHaveTextContent('写入项目数据');
expect(dialog).not.toHaveTextContent('读取项目名称');
expect(dialog).not.toHaveTextContent('data-service.documents');
expect(dialog).not.toHaveTextContent('按平台包含');
expect(dialog).not.toHaveTextContent('版本 1.0.0');
expect(dialog).toHaveTextContent('使用设置');
expect(dialog).toHaveTextContent('创建开发数据空间');
fireEvent.click(screen.getByRole('button', { name: '从项目移除开发数据服务' }));
expect(view.onCommand).not.toHaveBeenCalled();
expect(screen.getByRole('alertdialog')).toHaveTextContent('Project A');
fireEvent.click(screen.getByRole('button', { name: '确认从项目移除' }));
expect(view.onCommand).toHaveBeenCalledWith({
kind: 'disable_project', projectId: 'project-a', pluginId: 'makelore.data-service',
});
});
it('describes local plugins by capability without exposing implementation details', () => {
const scriptSkill: PluginWorkspaceItem = {
...localItem,
key: 'local:makelore-project-scaffold',
pluginId: 'makelore-project-scaffold',
packageId: 'makelore-project-scaffold',
title: 'MakeLore 项目初始化',
local: {
...localItem.local!,
packageId: 'makelore-project-scaffold',
displayName: 'MakeLore 项目初始化',
kind: 'skill-only',
skillEntries: [{
id: 'makelore-project-scaffold',
entryPath: 'skills/makelore-project-scaffold/SKILL.md',
}],
extensionEntries: [],
},
};
render(<MemoryRouter><PluginsView {...props(scriptSkill)} /></MemoryRouter>);
const dialog = screen.getByRole('dialog', { name: 'MakeLore 项目初始化' });
expect(dialog).toHaveTextContent('为智能体提供可在本机使用的扩展能力。');
expect(dialog).not.toHaveTextContent('Skill 脚本');
expect(dialog).not.toHaveTextContent('Pi extension');
expect(dialog).not.toHaveTextContent('完整桌面权限');
});
it('shows a friendly unavailable message and keeps the applicable sign-in action', () => {
const unavailableItem: PluginWorkspaceItem = {
...officialItem,
title: 'Notes',
delivery: 'account_unknown',
projectState: 'not_applicable',
version: '1.0.0',
stale: true,
unavailable: true,
suspended: true,
retired: true,
deviceReason: '当前客户端与新版本不兼容',
commands: [{ kind: 'sign_in' }],
official: {
...officialItem.official!,
installation: {
status: 'unavailable',
pluginId: 'makelore.notes',
version: '1.0.0',
channel: 'stable',
reason: '当前客户端与新版本不兼容',
},
project: null,
},
};
const view = props(unavailableItem);
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
const dialog = screen.getByRole('dialog', { name: 'Notes' });
expect(dialog).toHaveTextContent('这项能力目前暂不可用');
expect(dialog).not.toHaveTextContent('缓存');
expect(dialog).not.toHaveTextContent('已暂停');
expect(dialog).not.toHaveTextContent('已退役');
expect(dialog).not.toHaveTextContent('1.0.0');
expect(dialog).not.toHaveTextContent('当前客户端与新版本不兼容');
fireEvent.click(screen.getByRole('button', { name: '登录后添加Notes' }));
expect(view.onCommand).toHaveBeenCalledWith({ kind: 'sign_in' });
});
it('keeps Marketplace details capability-focused without exposing operation or pricing fields', () => {
const detailItem: PluginWorkspaceItem = {
...officialItem,
title: 'Notes',
summary: '记录、整理并回顾项目笔记。',
projectState: 'not_applicable',
assignedAgentIds: [],
assignedAgentNames: [],
official: {
...officialItem.official!,
project: null,
detail: {
...officialItem.official!.detail!,
pluginId: 'makelore.notes',
title: 'Notes',
operations: [{
capabilityId: 'notes',
operation: 'write',
executionMode: 'job',
billing: {
mode: 'platform_metered',
status: null,
notice: '每次由服务端结算',
entitlementScope: 'account',
unitName: 'request',
unitSize: 2,
ratePoints: '3',
minimumChargePoints: '1',
roundingMode: 'ceil',
pricingVersion: 'pricing-7',
},
enabled: true,
}],
},
},
};
const view = {
...props(detailItem),
activeProjectId: null,
activeProjectName: null,
};
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
const dialog = screen.getByRole('dialog', { name: 'Notes' });
expect(dialog).toHaveTextContent('记录、整理并回顾项目笔记。');
expect(dialog).not.toHaveTextContent('notes');
expect(dialog).not.toHaveTextContent('write');
expect(dialog).not.toHaveTextContent('每次由服务端结算');
expect(dialog).not.toHaveTextContent('request');
expect(dialog).not.toHaveTextContent('Token Point');
expect(dialog).not.toHaveTextContent('pricing-7');
});
it.each([
['stale'],
['unavailable'],
] as const)('does not expose the %s policy implementation state in details', (projectPolicyStatus) => {
const policyItem: PluginWorkspaceItem = { ...officialItem, projectPolicyStatus };
render(<MemoryRouter><PluginsView {...props(policyItem)} /></MemoryRouter>);
const dialog = screen.getByRole('dialog', { name: '开发数据服务' });
expect(dialog).not.toHaveTextContent('当前项目策略使用缓存');
expect(dialog).not.toHaveTextContent('当前项目策略不可用');
});
it('deduplicates human-facing capability descriptions and hides their technical identity', () => {
const capabilityTool = {
name: 'notes_write',
label: '整理笔记',
description: '整理并保存项目笔记。',
mutation: 'write' as const,
permissions: ['project.notes.write'],
};
const projectPolicy = {
...projectPlugin,
capabilities: [{
id: 'notes.internal',
operations: [
{
id: 'write_internal',
billing: {
mode: 'platform_metered' as const,
availability: 'available' as const,
notice: '当前项目策略价格',
ratePoints: '7',
},
tool: capabilityTool,
},
{
id: 'write_duplicate',
billing: {
mode: 'included' as const,
availability: 'available' as const,
notice: '目录包含读取',
},
tool: capabilityTool,
},
],
}],
};
const detailItem: PluginWorkspaceItem = {
...officialItem,
title: 'Notes',
official: {
...officialItem.official!,
project: projectPolicy,
detail: {
...officialItem.official!.detail!,
pluginId: 'makelore.notes',
title: 'Notes',
operations: [
{
capabilityId: 'notes',
operation: 'write',
executionMode: 'synchronous',
billing: {
mode: 'platform_metered', status: null, notice: '目录价格',
entitlementScope: null, unitName: 'catalog-request', unitSize: 1,
ratePoints: '3', minimumChargePoints: null, roundingMode: 'ceil',
pricingVersion: 'pricing-3',
},
enabled: true,
},
{
capabilityId: 'notes',
operation: 'read',
executionMode: 'synchronous',
billing: {
mode: 'included', status: null, notice: '目录包含读取',
entitlementScope: null, unitName: null, unitSize: null,
ratePoints: null, minimumChargePoints: null, roundingMode: null,
pricingVersion: 'pricing-3',
},
enabled: true,
},
],
},
},
};
render(<MemoryRouter><PluginsView {...props(detailItem)} /></MemoryRouter>);
const dialog = screen.getByRole('dialog', { name: 'Notes' });
expect(screen.getAllByText('整理笔记')).toHaveLength(1);
expect(screen.getAllByText('整理并保存项目笔记。')).toHaveLength(1);
expect(dialog).not.toHaveTextContent('notes.internal');
expect(dialog).not.toHaveTextContent('write_internal');
expect(dialog).not.toHaveTextContent('project.notes.write');
expect(dialog).not.toHaveTextContent('当前项目策略价格');
expect(dialog).not.toHaveTextContent('目录价格');
expect(dialog).not.toHaveTextContent('Token Point');
});
it('dispatches non-destructive detail commands directly and closes from the dialog control', () => {
const view = props(officialItem);
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
fireEvent.click(screen.getByRole('button', { name: '打开开发数据服务设置' }));
expect(view.onCommand).toHaveBeenCalledWith({
kind: 'open_settings', projectId: 'project-a', pluginId: 'makelore.data-service',
});
fireEvent.click(screen.getByRole('button', { name: '关闭插件详情' }));
expect(view.onSelect).toHaveBeenCalledWith(null);
});
it('clears a destructive confirmation when the active project changes', () => {
const viewA = props(officialItem);
const { rerender } = render(<MemoryRouter><PluginsView {...viewA} /></MemoryRouter>);
fireEvent.click(screen.getByRole('button', { name: '从项目移除开发数据服务' }));
expect(screen.getByRole('alertdialog')).toHaveTextContent('Project A');
const projectBItem: PluginWorkspaceItem = {
...officialItem,
commands: [
{ kind: 'disable_project', projectId: 'project-b', pluginId: 'makelore.data-service' },
],
};
const viewB = {
...props(projectBItem),
activeProjectId: 'project-b',
activeProjectName: 'Project B',
};
rerender(<MemoryRouter><PluginsView {...viewB} /></MemoryRouter>);
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
expect(viewA.onCommand).not.toHaveBeenCalled();
expect(viewB.onCommand).not.toHaveBeenCalled();
});
it('closes the detail with Escape and returns focus to the originating card', async () => {
function Harness() {
const [selected, setSelected] = useState<PluginWorkspaceItem | null>(null);
const view = props(selected);
view.onSelect = vi.fn((key: PluginWorkspaceItem['key'] | null) => {
setSelected(key === officialItem.key ? officialItem : null);
});
return <PluginsView {...view} />;
}
render(<MemoryRouter><Harness /></MemoryRouter>);
const trigger = screen.getByRole('button', { name: '查看开发数据服务详情' });
fireEvent.click(trigger);
const dialog = screen.getByRole('dialog', { name: '开发数据服务' });
expect(dialog).toBeVisible();
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
fireEvent.keyDown(dialog, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog', { name: '开发数据服务' })).not.toBeInTheDocument());
await waitFor(() => expect(trigger).toHaveFocus());
});
});