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

781 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 {
PluginWorkspaceFilters,
PluginWorkspaceItem,
PluginWorkspaceProjection,
} from '@/pages/Plugins/plugin-workspace-model';
const filters: PluginWorkspaceFilters = {
scope: 'project', source: 'all', state: 'all', search: '', selectedKey: null,
};
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_agent_assignment', 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),
filters: { ...filters, selectedKey: selected?.key ?? null },
activeProjectId: 'project-a',
activeProjectName: 'Project A',
sourceErrors: [],
sourceLoading: [],
dataService: null,
dataServicePending: {},
onFiltersChange: vi.fn(),
onRefresh: 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('/plugins')}></button>
<button
type="button"
onClick={() => navigate('/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('keeps the production URL, scope control, and fallback notice aligned across in-place changes', async () => {
preparePluginsRouteState();
render(
<MemoryRouter initialEntries={['/plugins?scope=project']}>
<PluginsRouteHarness />
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=all&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('all');
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(
'/plugins?scope=project&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('project');
fireEvent.click(screen.getByRole('button', { name: '打开非法插件地址' }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=project&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('project');
act(() => codingWorkspaceStore.setState({ activeProjectId: null, activeProject: null }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=all&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('all');
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
});
it('keeps old device and project rows visible and labels each affected snapshot cached after refresh errors', 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={['/plugins?scope=project&source=all&state=all']}><Plugins /></MemoryRouter>);
for (const name of ['开发数据服务', 'Pi Web Search', 'makelore.retained']) {
const heading = await screen.findByRole('heading', { name });
expect(heading.closest('article')).toHaveTextContent('缓存');
}
expect(screen.getAllByRole('alert')).toHaveLength(2);
});
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={[
'/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 unified workspace and exposes URL-backed filter changes', () => {
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(/MakeLore 运营发布官方插件/)).toBeVisible();
expect(screen.getByText(/获取、项目启用与伙伴分配彼此独立/)).toBeVisible();
expect(screen.getByText('开发数据服务')).toBeVisible();
expect(screen.getByText('Pi Web Search')).toBeVisible();
expect(screen.getByRole('heading', { name: '本机全局生效' })).toBeVisible();
expect(screen.getByText('本机全局已启用')).toBeVisible();
expect(screen.getByText('随 MakeLore 提供')).toBeVisible();
expect(screen.getByText('已分配 1 位伙伴')).toBeVisible();
expect(screen.getAllByRole('alert')).toHaveLength(2);
expect(screen.queryByRole('textbox', { name: /包|路径|source/i })).not.toBeInTheDocument();
expect(screen.getByText(/通过对话安装/)).toBeVisible();
fireEvent.change(screen.getByLabelText('范围'), { target: { value: 'all' } });
expect(view.onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({ scope: 'all' }));
fireEvent.change(screen.getByLabelText('来源'), { target: { value: 'local' } });
expect(view.onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({ source: 'local' }));
fireEvent.change(screen.getByLabelText('状态'), { target: { value: 'enabled' } });
expect(view.onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({ state: 'enabled' }));
fireEvent.change(screen.getByRole('searchbox', { name: '搜索插件' }), { target: { value: 'web' } });
fireEvent.submit(screen.getByRole('search'));
expect(view.onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({ search: 'web' }));
});
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.getAllByText('随应用提供')).toHaveLength(2);
expect(screen.queryByText('未下载官方包')).not.toBeInTheDocument();
});
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('data-service.documents');
expect(dialog).toHaveTextContent('按平台包含');
expect(dialog).toHaveTextContent('小明');
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 executable Skill scripts without calling them Pi extensions', () => {
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('包含可执行 Pi extension');
});
it('shows cached, suspended, retired, and retained-device reason copy with an explicit 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).toHaveTextContent('已暂停');
expect(dialog).toHaveTextContent('已退役');
expect(dialog).toHaveTextContent('官方包 1.0.0');
expect(dialog).toHaveTextContent('旧设备版本已保留:当前客户端与新版本不兼容');
fireEvent.click(screen.getByRole('button', { name: '登录后免费获取Notes' }));
expect(view.onCommand).toHaveBeenCalledWith({ kind: 'sign_in' });
});
it('shows Marketplace detail operations and server-owned pricing without an active project', () => {
const detailItem: PluginWorkspaceItem = {
...officialItem,
title: 'Notes',
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('notes');
expect(dialog).toHaveTextContent('write');
expect(dialog).toHaveTextContent('每次由服务端结算');
expect(dialog).toHaveTextContent('计费单位request');
expect(dialog).toHaveTextContent('单位大小2');
expect(dialog).toHaveTextContent('每单位 Token Point3');
expect(dialog).toHaveTextContent('最低扣点1');
expect(dialog).toHaveTextContent('价格版本pricing-7');
});
it.each([
['stale', '当前项目策略使用缓存,所示能力与价格可能不是最新状态。'],
['unavailable', '当前项目策略不可用,无法确认最新能力与价格。'],
] as const)('shows %s project policy status beside policy-owned capability and pricing', (projectPolicyStatus, copy) => {
const policyItem: PluginWorkspaceItem = { ...officialItem, projectPolicyStatus };
render(<MemoryRouter><PluginsView {...props(policyItem)} /></MemoryRouter>);
expect(screen.getByRole('dialog', { name: '开发数据服务' })).toHaveTextContent(copy);
});
it('does not mark current project policy as stale or unavailable', () => {
render(<MemoryRouter><PluginsView {...props(officialItem)} /></MemoryRouter>);
const dialog = screen.getByRole('dialog', { name: '开发数据服务' });
expect(dialog).not.toHaveTextContent('当前项目策略使用缓存');
expect(dialog).not.toHaveTextContent('当前项目策略不可用');
});
it('deduplicates operation identity while preferring the current project policy projection', () => {
const projectPolicy = {
...projectPlugin,
capabilities: [{
id: 'notes',
operations: [{
id: 'write',
billing: {
mode: 'platform_metered' as const,
availability: 'available' as const,
notice: '当前项目策略价格',
unitName: 'project-request',
unitSize: 1,
ratePoints: '7',
minimumChargePoints: '2',
pricingVersion: 9,
roundingMode: 'ceil' as const,
},
tool: null,
}],
}],
};
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('write')).toHaveLength(1);
expect(dialog).toHaveTextContent('read');
expect(dialog).toHaveTextContent('当前项目策略价格');
expect(dialog).not.toHaveTextContent('目录价格');
expect(dialog).toHaveTextContent('每单位 Token Point7');
expect(dialog).toHaveTextContent('价格版本9');
expect(dialog).toHaveTextContent('目录包含读取');
});
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_agent_assignment', 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());
});
});