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

865 lines
32 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: {},
activeTab: 'mine' as const,
onTabChange: vi.fn(),
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', () => {
const view = props();
render(<MemoryRouter><PluginsView {...view} embedded /></MemoryRouter>);
expect(screen.getByTestId('plugins-page')).toBeVisible();
expect(screen.queryByRole('heading', { name: '插件', level: 1 })).not.toBeInTheDocument();
expect(screen.getByRole('tab', { name: /可选/ })).toHaveAttribute('aria-selected', 'false');
expect(screen.getByRole('tab', { name: /已安装/ })).toHaveAttribute('aria-selected', 'true');
fireEvent.mouseDown(screen.getByRole('tab', { name: /可选/ }), { button: 0, ctrlKey: false });
expect(view.onTabChange).toHaveBeenCalledWith('available');
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();
expect(screen.getByText('Skill + 插件')).toBeVisible();
});
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=available',
));
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=all&source=all&state=available',
));
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '打开非法插件地址' }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/project-config/plugins?scope=all&source=all&state=available',
));
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=available',
));
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
expect(screen.queryByText('当前没有打开的项目,已显示全部插件。')).not.toBeInTheDocument();
});
it('keeps installed 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=mine']}><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('clears legacy source filters when switching to installed so local Skills remain visible', 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: 'included',
includedOperationCount: 1,
meteredOperationCount: 0,
stableVersion: '1.0.0',
betaVersion: null,
}],
nextCursor: null,
total: 1,
catalogGeneration: 1,
etag: 'catalog-1',
pricingVersionId: null,
stale: false,
fetchedAt: 1,
},
});
devicePackageStore.setState({
index: {
schemaVersion: 1,
generation: 2,
packages: [{
...localItem.local!,
packageId: 'local-writing',
displayName: 'Local Writing',
source: {
kind: 'file',
requested: '/Users/example/.codex/skills/local-writing/SKILL.md',
resolved: '/Users/example/.codex/skills/local-writing/SKILL.md',
},
kind: 'skill-only',
skillEntries: [{ id: 'local-writing', entryPath: 'SKILL.md' }],
extensionEntries: [],
confirmedExecutableCode: false,
}],
},
});
render(
<MemoryRouter initialEntries={[
'/project-config/plugins?scope=all&source=official&state=available',
]}>
<PluginsRouteHarness />
</MemoryRouter>,
);
expect(await screen.findByRole('heading', { name: 'Notes' })).toBeVisible();
fireEvent.mouseDown(screen.getByRole('tab', { name: /已安装/ }), { button: 0, ctrlKey: false });
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/project-config/plugins?scope=all&source=all&state=mine',
));
expect(await screen.findByRole('heading', { name: 'Local Writing' })).toBeVisible();
expect(screen.getByText('Skill')).toBeVisible();
});
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=available&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('插件与 Skill 统一管理;云端能力在这里下发,本机安装的 Skill 也会集中显示。')).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: 1, 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('这是用户自行安装在本机的 Skill已统一收纳在插件模块中。');
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());
});
});