From c5020ae22c9bef1b5bc8190469998127842c4513 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 3 Sep 2026 14:29:21 +0800 Subject: [PATCH] fix: close plugin workspace acceptance gaps --- ...-plugin-navigation-remediation-c5e8a731.md | 148 +++++++++ src/pages/Plugins/PluginDetails.tsx | 136 ++++++++- src/pages/Plugins/index.tsx | 85 +++++- .../Plugins/plugin-workspace-controller.ts | 3 + src/pages/Plugins/plugin-workspace-model.ts | 111 +++++-- src/pages/Plugins/plugin-workspace-query.ts | 18 ++ .../unit/plugin-workspace-controller.test.ts | 3 +- tests/unit/plugin-workspace-model.test.ts | 120 ++++++++ tests/unit/plugin-workspace-query.test.ts | 25 ++ tests/unit/plugins-page.test.tsx | 289 +++++++++++++++++- 10 files changed, 885 insertions(+), 53 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260903-plugin-navigation-remediation-c5e8a731.md diff --git a/.project-docs/30-worklog/tasks/20260903-plugin-navigation-remediation-c5e8a731.md b/.project-docs/30-worklog/tasks/20260903-plugin-navigation-remediation-c5e8a731.md new file mode 100644 index 00000000..2f8aca8f --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260903-plugin-navigation-remediation-c5e8a731.md @@ -0,0 +1,148 @@ +# Task: Plugin navigation remediation + +## Identity + +- Task ID: 20260903-plugin-navigation-remediation-c5e8a731 +- Mode: Feature +- Branch: codex/20260903-plugin-navigation-remediation-c5e8a731-plugin-navigation-remediation +- Worktree: C:\Users\7brot\.codex\worktrees\plugin-nav-remediation-c5e8a731\makelore +- Base commit: 1b1f206dd8aeb7bf69e61b0cd684f70accce9e26 +- Owner: codex-01a0656d +- Status: Ready for Integration + +## Scope + +- Remediate the five fixed-range Spec failures reported by task + `20260903-plugin-navigation-acceptance-5e8b3c72` on implementation head + `1b1f206dd8aeb7bf69e61b0cd684f70accce9e26`. +- Own only `src/pages/Plugins/**`, their focused unit/page tests, and this task + record. No store, Electron Main, server, runtime, billing authority, sidebar, + module guard, or legacy redirect change is in scope. +- Deliver one remediation commit for a fresh fixed-range Standards + Spec review; + do not merge `main`, push, create a PR, deploy, publish, package, or install. + +## Intent And Constraints + +- Concurrent Task Gate passed for the exact identity/base/worktree above. The + prior implementation and acceptance tasks are Ready for Integration and are + read-only. The AI Design peer explicitly excludes Plugin pages/navigation, so + no file or semantic ownership overlap exists. +- Planning Gate passed after reading the exact project-memory tree, authoritative + Spec `ML-PLUGIN-NAV-001`, ADR-006, prior implementation/acceptance records, and + the current Renderer projection/page/controller plus Marketplace DTO/store + contracts. +- Confirmed public TDD seams are the pure projection/query resolver, the local + command dispatcher, and `PluginsView`/route composition. Tests must fail before + each production correction and observe URL, commands, copy, and detail output + rather than private implementation calls. +- Continuously canonicalize invalid URL state without a one-shot ref or replace + loop, while preserving one visible no-project fallback notice. +- Preserve a source-qualified `retained:` project selection even when Catalog or + Library recognizes the same ID; it may use those sources only for display + metadata and may dispatch only disable for the projection's original project ID. +- Treat `status=unavailable` plus retained version/channel/reason as an existing + device package for cleanup and same-channel update semantics. Show its reason, + mark retained snapshots stale after refresh failure, and distinguish suspended + from retired without inferring state across sources. +- Render operation-level pricing only from Marketplace detail or current project + policy, deduplicated by capability/operation identity and without client-side + price calculation. +- Distinguish signed-out acquisition from authenticated Library failure. Signed-out + users receive an explicit login command; authenticated unknown Library state + remains fail-closed. +- Existing DTOs already expose every required field, so no authority/interface + conflict is present. If later evidence contradicts that conclusion, stop the + affected range rather than add a compatibility layer or broaden ownership. +- Preserve the root workspace and all peer worktrees; in particular do not write + or clean `D:\\Datas\\OthersProjects\\makelore`. + +## Project Context Loaded + +- Product goal: one understandable Plugin workspace that composes, but never + merges, Catalog, Library, device package, project, Agent, admission, and billing + authorities. +- Current state: official bundled Plugins, conversation-only Device Packages, and + selected-model native Web Search already have separate delivery/runtime rules; + the unified Renderer implementation is complete but failed five acceptance roots. +- Relevant decisions: Electron Main retains runtime/credential ownership; Device + Packages are device-global and child workers remain empty; account acquisition, + device delivery, project enablement, Agent assignment, admission, and billing do + not advance one another. +- Relevant evidence: the acceptance record identifies unreachable scope fallback, + lost recognized retained IDs, mishandled unavailable installations/stale status, + omitted detail operations/pricing, and missing signed-out action as exact failures. +- Canonical-memory candidate remains the prior implementation task's deferred + module-map promotion; this feature remediation does not edit integrated memory. +- Known documentation drift: `read-before-planning.md` names old locations for + `memory-index.md` and `current-state.md`; the exact existing files were resolved + with `rg --files` and read successfully, so this is non-blocking. + +## Outcome + +- Replaced one-shot URL normalization with a pure query resolution result and a + Router-location marker that carries the no-project fallback notice across the + canonical replace. Canonical URL and scope controls now remain aligned when the + active project changes or the same mounted route receives a new invalid query, + without a `setSearchParams` loop or effect-owned React state. +- Preserved catalog/Library-recognized `unknownPluginIds` as source-qualified + `retained:` rows in both project and all scopes. They reuse only recognizable + display metadata and expose exactly one mutation: disable against the project + ID recorded by the project projection. +- Treated `MarketplaceInstallation.status=unavailable` with a retained version + and channel as an existing device package for same-channel update and device + removal, while keeping runtime/project actions fail-closed. Cards and details + display the retained version reason, failed retained snapshots show `缓存`, and + suspended/retired states have distinct copy. +- Added a read-only detail operation projection. Marketplace detail operations + render without a project; the current project policy replaces a duplicate + operation as one complete server-owned billing projection, while detail-only + operations remain visible. No pricing is calculated or inferred locally. +- Added an explicit `sign_in` command for signed-out free catalog entries and + dispatch it to the existing `/login` route. Authenticated Library failure still + reports unknown delivery and exposes no acquisition mutation. +- No route, store, Electron Main, server, runtime, billing authority, sidebar, or + E2E file changed. No new authority, fallback, feature flag, compatibility layer, + or visible local-package installation entry was added. +- This task supplies a remediation candidate only. It does not claim the fresh + fixed-range Standards + Spec review has passed. + +## Verification + +- TDD URL RED: focused query/page tests failed because the production composition + erased `scope=project` before the fallback notice and only normalized the first + URL. GREEN: the final query + page run passed 2 files / 13 tests. +- TDD projection/action RED: focused model/controller/page tests produced the six + expected failures for retained unavailable installation semantics, stale and + lifecycle copy, recognized retained IDs, signed-out action, login dispatch, and + visible status copy. GREEN: the focused five-file suite passed 5 files / 34 tests. +- TDD detail RED: the two new page assertions failed because Marketplace detail + operations were absent without a project and a detail-only operation disappeared + when project policy existed. GREEN: `tests/unit/plugins-page.test.tsx` passed all + 9 tests with server pricing output and duplicate identity coverage. +- `corepack pnpm run typecheck`: passed (`tsc --noEmit`). +- `corepack pnpm run lint:check`: passed with zero errors. Five pre-existing + warnings remain in `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`; + neither file is owned or changed by this task. +- `corepack pnpm test`: passed 225 files / 1830 tests with 2 skipped, followed by + the isolated pressure file passing 1 file / 1 test (aggregate 226 files / 1831 + passed / 2 skipped). +- `corepack pnpm run build:vite`: passed all Renderer, Electron Main, Preload, and + release utility builds. Existing Browserslist-age, mixed dynamic/static import, + and large-chunk warnings remain non-blocking. +- `corepack pnpm run test:e2e -- tests/e2e/plugin-marketplace.spec.ts tests/e2e/project-plugins.spec.ts --reporter=line`: + passed the existing 4/4 Electron flows. The command also rebuilt all production + targets successfully. The E2E set was not expanded because the remediation is + covered at the production composition/pure projection seams and did not require + a route or Host API change. +- `git diff --check`: passed before documentation closeout. + +## Follow-ups + +- Run fresh fixed-range Standards + Spec review over + `959e2faf03dfacb3c8e2035e44774436c752d4b0...remediation HEAD`; do not integrate + or merge `main` before that independent acceptance completes. + +## Promotion Candidates + +- None. This remediation changes no durable authority or architecture decision; + the prior implementation task's module-map candidate remains the correct owner. diff --git a/src/pages/Plugins/PluginDetails.tsx b/src/pages/Plugins/PluginDetails.tsx index 785f4e80..c16aecd4 100644 --- a/src/pages/Plugins/PluginDetails.tsx +++ b/src/pages/Plugins/PluginDetails.tsx @@ -14,6 +14,96 @@ import { import type { DataServiceInstanceState } from '../../../shared/data-service'; import type { PluginWorkspaceCommand, PluginWorkspaceItem } from './plugin-workspace-model'; +interface DetailOperation { + capabilityId: string; + operationId: string; + executionMode: 'synchronous' | 'job' | null; + enabled: boolean | null; + mutation: 'read' | 'write' | 'destructive' | null; + description: string | null; + billing: { + mode: 'included' | 'platform_metered' | 'external_account'; + availability: 'available' | 'unavailable' | null; + status: 'billing_unavailable' | null; + notice: string; + entitlementScope: string | null; + unitName: string | null; + unitSize: number | null; + ratePoints: string | null; + minimumChargePoints: string | null; + roundingMode: 'ceil' | null; + pricingVersion: string | number | null; + }; +} + +function detailOperations(item: PluginWorkspaceItem): DetailOperation[] { + const operations = new Map(); + for (const operation of item.official?.detail?.operations ?? []) { + operations.set(`${operation.capabilityId}:${operation.operation}`, { + capabilityId: operation.capabilityId, + operationId: operation.operation, + executionMode: operation.executionMode, + enabled: operation.enabled, + mutation: null, + description: null, + billing: { + ...operation.billing, + availability: null, + }, + }); + } + for (const capability of item.official?.project?.capabilities ?? []) { + for (const operation of capability.operations) { + const key = `${capability.id}:${operation.id}`; + const catalogOperation = operations.get(key); + operations.set(key, { + capabilityId: capability.id, + operationId: operation.id, + executionMode: catalogOperation?.executionMode ?? null, + enabled: catalogOperation?.enabled ?? null, + mutation: operation.tool?.mutation ?? null, + description: operation.tool?.description ?? null, + billing: { + mode: operation.billing.mode, + availability: operation.billing.availability, + status: null, + notice: operation.billing.notice, + entitlementScope: null, + unitName: operation.billing.unitName ?? null, + unitSize: operation.billing.unitSize ?? null, + ratePoints: operation.billing.ratePoints ?? null, + minimumChargePoints: operation.billing.minimumChargePoints ?? null, + roundingMode: operation.billing.roundingMode ?? null, + pricingVersion: operation.billing.pricingVersion ?? null, + }, + }); + } + } + return [...operations.values()]; +} + +function operationGroups(operations: readonly DetailOperation[]): Array<{ + capabilityId: string; + operations: DetailOperation[]; +}> { + const groups = new Map(); + for (const operation of operations) { + const group = groups.get(operation.capabilityId) ?? []; + group.push(operation); + groups.set(operation.capabilityId, group); + } + return [...groups].map(([capabilityId, groupOperations]) => ({ + capabilityId, + operations: groupOperations, + })); +} + +function operationBillingMode(mode: DetailOperation['billing']['mode']): string { + if (mode === 'included') return '包含'; + if (mode === 'platform_metered') return '按 Token Point'; + return '外部账号'; +} + const SOURCE_TEXT = { official: '官方', local: '本机', @@ -60,6 +150,7 @@ function commandLabel(command: PluginWorkspaceCommand, title: string): string { case 'enable_local': return `本机全局启用${title}`; case 'disable_local': return `本机全局停用${title}`; case 'remove_local': return `移除本机包${title}`; + case 'sign_in': return `登录后免费获取${title}`; case 'open_agent_assignment': return `分配${title}给伙伴`; case 'open_settings': return `查看${title}插件设置`; } @@ -126,6 +217,7 @@ export function PluginDetails(props: PluginDetailsProps) { const project = props.item.official?.project ?? null; const detail = props.item.official?.detail ?? null; const installation = props.item.official?.installation ?? null; + const operations = detailOperations(props.item); const showDataService = project?.enabled && project.settingsSurface === 'data-service'; const confirmCopy = confirmation @@ -149,7 +241,10 @@ export function PluginDetails(props: PluginDetailsProps) {
{SOURCE_TEXT[props.item.source]} {props.item.stale ? 缓存 : null} - {props.item.unavailable ? 暂不可用 : null} + {props.item.suspended ? 已暂停 : null} + {props.item.retired ? 已退役 : null} + {props.item.unavailable && !props.item.suspended && !props.item.retired + ? 暂不可用 : null}
{props.item.title} @@ -190,6 +285,11 @@ export function PluginDetails(props: PluginDetailsProps) {
{PROJECT_TEXT[props.item.projectState]}
+ {props.item.deviceReason ? ( +

+ {installation?.version ? '旧设备版本已保留' : '设备包不可用'}:{props.item.deviceReason} +

+ ) : null}
@@ -217,20 +317,38 @@ export function PluginDetails(props: PluginDetailsProps) {

能力与权限

- {project?.capabilities.length ? ( + {operations.length ? (
- {project.capabilities.map((capability) => ( -
-

{capability.id}

+ {operationGroups(operations).map((capability) => ( +
+

{capability.capabilityId}

    {capability.operations.map((operation) => ( -
  • +
  • - {operation.id} - {operation.tool ? {operation.tool.mutation} : SDK} + {operation.operationId} + {operation.mutation + ? {operation.mutation} + : operation.executionMode + ? {operation.executionMode === 'job' ? '异步任务' : '同步'} + : SDK} + {operation.enabled === false ? 服务端已停用 : null} + {operation.billing.availability === 'unavailable' + || operation.billing.status === 'billing_unavailable' + ? 计费暂不可用 : null}
    - {operation.tool ?

    {operation.tool.description}

    : null} + {operation.description ?

    {operation.description}

    : null}

    {operation.billing.notice}

    +
    +
    计费方式:
    {operationBillingMode(operation.billing.mode)}
    + {operation.billing.entitlementScope ?
    权益范围:
    {operation.billing.entitlementScope}
    : null} + {operation.billing.unitName ?
    计费单位:
    {operation.billing.unitName}
    : null} + {operation.billing.unitSize !== null ?
    单位大小:
    {operation.billing.unitSize}
    : null} + {operation.billing.ratePoints !== null ?
    每单位 Token Point:
    {operation.billing.ratePoints}
    : null} + {operation.billing.minimumChargePoints !== null ?
    最低扣点:
    {operation.billing.minimumChargePoints}
    : null} + {operation.billing.pricingVersion !== null ?
    价格版本:
    {operation.billing.pricingVersion}
    : null} + {operation.billing.roundingMode === 'ceil' ?
    舍入方式:
    向上取整
    : null} +
  • ))}
diff --git a/src/pages/Plugins/index.tsx b/src/pages/Plugins/index.tsx index a6ee99dc..1e0987d4 100644 --- a/src/pages/Plugins/index.tsx +++ b/src/pages/Plugins/index.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, type FormEvent } from 'react'; import { AlertCircle, Puzzle, RefreshCw, Search } from 'lucide-react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -22,7 +22,7 @@ import type { PluginWorkspaceState, } from './plugin-workspace-model'; import { buildPluginWorkspaceProjection } from './plugin-workspace-model'; -import { parsePluginWorkspaceSearch, serializePluginWorkspaceSearch } from './plugin-workspace-query'; +import { resolvePluginWorkspaceSearch, serializePluginWorkspaceSearch } from './plugin-workspace-query'; const DELIVERY_TEXT = { system_included: '随 MakeLore 提供', @@ -102,7 +102,9 @@ function PluginCard({ {cardSource(item)} {DELIVERY_TEXT[item.delivery]} {item.updateAvailable ? 可更新 : null} - {item.unavailable ? 不可用 : null} + {item.suspended ? 已暂停 : null} + {item.retired ? 已退役 : null} + {item.unavailable && !item.suspended && !item.retired ? 不可用 : null} {item.stale ? 缓存 : null}
{item.title} @@ -113,6 +115,11 @@ function PluginCard({
伙伴
{agentText(item)}
计费
{BILLING_TEXT[item.billing]}
+ {item.deviceReason ? ( +

+ {item.official?.installation?.version ? '旧设备版本已保留' : '设备包不可用'}:{item.deviceReason} +

+ ) : null} + + + ); +} + +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( + + + , + ); + + 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('renders the unified workspace and exposes URL-backed filter changes', () => { const view = props(); view.sourceErrors = [ @@ -226,6 +343,170 @@ describe('PluginsView', () => { }); }); + 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(); + + 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(); + + 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 Point:3'); + expect(dialog).toHaveTextContent('最低扣点:1'); + expect(dialog).toHaveTextContent('价格版本:pricing-7'); + }); + + 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(); + + 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 Point:7'); + 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();