From 89557d3a36924125439b5b5de278b7f2724883e1 Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Wed, 3 Jun 2026 05:39:12 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(devboard):=20Batch=206=20DevTools=20?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E5=9B=9B=E9=A1=B5=EF=BC=88Contracts/Deps/Cov?= =?UTF-8?q?erage/Groups=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 平台级只读浏览四页,全部落 @gocell/devboard,零跨包 import、零后端调用、 零图形库依赖(graph/tree/matrix 纯 SVG/CSS)。严守「绝不伪造」:可派生数据 走 CELL_MANIFEST 真实派生,不可派生数据走明确标注的静态快照。 - /contracts: registry 自所有 cell 的 produces/consumes 聚合派生(serve/call 关系,useContractsRegistry);governance gates CH-01..06 + typed response envelope 为静态快照,页头 banner 标注,待接 gocell validate --strict。 - /deps: cell 依赖图自 dependsOnCells 派生,4 视图 list/graph/tree/matrix, 视图切换器复用 useRovingTablist(APG tablist + roving tabindex)。 - /coverage: 实施进度矩阵(手维护 meta,真相源 dev-coverage.jsx,PRD §514)。 - /groups: Smart Groups preview,规则静态、成员实时计算自真实 manifest 字段。 集成:4 路由(apps/web)+ 4 exports 子路径;PDP 门降级 requiredResource:'cell' (后端无 contract/dep/group resource),/coverage 仅 requiresAuth(dev-tool 自览)。 i18n contracts/deps/coverage/groups 四命名空间(zh-CN + en-US)。 测试:devboard +191 新测试全绿(360 总),apps/web 74 绿,新文件覆盖率 98%。 ref: dev-coverage.jsx / dev-extras3.jsx / dev-develop.jsx / dev-cell.jsx Closes #17 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/router/index.spec.ts | 37 ++ apps/web/src/router/index.ts | 38 ++ packages/core/src/i18n/messages/en-US.ts | 229 +++++++ packages/core/src/i18n/messages/zh-CN.ts | 229 +++++++ packages/devboard/README.md | 17 + packages/devboard/package.json | 6 +- .../contracts/GovernanceGatesPanel.spec.ts | 100 +++ .../contracts/GovernanceGatesPanel.vue | 170 +++++ .../src/components/deps/DepsGraphView.vue | 216 +++++++ .../src/components/deps/DepsListView.vue | 134 ++++ .../src/components/deps/DepsMatrixView.vue | 161 +++++ .../src/components/deps/DepsTreeView.vue | 192 ++++++ .../src/components/deps/DepsViewBar.spec.ts | 132 ++++ .../src/components/deps/DepsViewBar.vue | 93 +++ .../src/components/deps/DepsViews.spec.ts | 304 +++++++++ .../composables/useContractsRegistry.spec.ts | 156 +++++ .../src/composables/useContractsRegistry.ts | 124 ++++ .../src/composables/useDepsGraph.spec.ts | 129 ++++ .../devboard/src/composables/useDepsGraph.ts | 112 ++++ .../devboard/src/data/coverageMatrix.spec.ts | 78 +++ packages/devboard/src/data/coverageMatrix.ts | 222 +++++++ .../devboard/src/data/governanceGates.spec.ts | 39 ++ packages/devboard/src/data/governanceGates.ts | 39 ++ .../src/data/responseEnvelopes.spec.ts | 38 ++ .../devboard/src/data/responseEnvelopes.ts | 52 ++ .../devboard/src/data/smartGroups.spec.ts | 122 ++++ packages/devboard/src/data/smartGroups.ts | 114 ++++ .../devboard/src/views/ContractsView.spec.ts | 171 +++++ packages/devboard/src/views/ContractsView.vue | 603 ++++++++++++++++++ .../devboard/src/views/CoverageView.spec.ts | 305 +++++++++ packages/devboard/src/views/CoverageView.vue | 557 ++++++++++++++++ packages/devboard/src/views/DepsView.spec.ts | 209 ++++++ packages/devboard/src/views/DepsView.vue | 304 +++++++++ .../devboard/src/views/GroupsView.spec.ts | 265 ++++++++ packages/devboard/src/views/GroupsView.vue | 532 +++++++++++++++ 35 files changed, 6228 insertions(+), 1 deletion(-) create mode 100644 packages/devboard/src/components/contracts/GovernanceGatesPanel.spec.ts create mode 100644 packages/devboard/src/components/contracts/GovernanceGatesPanel.vue create mode 100644 packages/devboard/src/components/deps/DepsGraphView.vue create mode 100644 packages/devboard/src/components/deps/DepsListView.vue create mode 100644 packages/devboard/src/components/deps/DepsMatrixView.vue create mode 100644 packages/devboard/src/components/deps/DepsTreeView.vue create mode 100644 packages/devboard/src/components/deps/DepsViewBar.spec.ts create mode 100644 packages/devboard/src/components/deps/DepsViewBar.vue create mode 100644 packages/devboard/src/components/deps/DepsViews.spec.ts create mode 100644 packages/devboard/src/composables/useContractsRegistry.spec.ts create mode 100644 packages/devboard/src/composables/useContractsRegistry.ts create mode 100644 packages/devboard/src/composables/useDepsGraph.spec.ts create mode 100644 packages/devboard/src/composables/useDepsGraph.ts create mode 100644 packages/devboard/src/data/coverageMatrix.spec.ts create mode 100644 packages/devboard/src/data/coverageMatrix.ts create mode 100644 packages/devboard/src/data/governanceGates.spec.ts create mode 100644 packages/devboard/src/data/governanceGates.ts create mode 100644 packages/devboard/src/data/responseEnvelopes.spec.ts create mode 100644 packages/devboard/src/data/responseEnvelopes.ts create mode 100644 packages/devboard/src/data/smartGroups.spec.ts create mode 100644 packages/devboard/src/data/smartGroups.ts create mode 100644 packages/devboard/src/views/ContractsView.spec.ts create mode 100644 packages/devboard/src/views/ContractsView.vue create mode 100644 packages/devboard/src/views/CoverageView.spec.ts create mode 100644 packages/devboard/src/views/CoverageView.vue create mode 100644 packages/devboard/src/views/DepsView.spec.ts create mode 100644 packages/devboard/src/views/DepsView.vue create mode 100644 packages/devboard/src/views/GroupsView.spec.ts create mode 100644 packages/devboard/src/views/GroupsView.vue diff --git a/apps/web/src/router/index.spec.ts b/apps/web/src/router/index.spec.ts index 2f4a190..da94b46 100644 --- a/apps/web/src/router/index.spec.ts +++ b/apps/web/src/router/index.spec.ts @@ -87,4 +87,41 @@ describe('router layout fork', () => { expect(m.meta.requiredAction).toBe('read') expect(m.meta.requiredResource).toBe('cell') }) + + it('renders /contracts nested under the shell, gate degraded to cell (Batch 6)', () => { + const m = router.resolve('/contracts') + expect(m.name).toBe('contracts') + expect(m.matched.length).toBeGreaterThanOrEqual(2) + expect(m.meta.requiresAuth).toBe(true) + expect(m.meta.requiredAction).toBe('read') + expect(m.meta.requiredResource).toBe('cell') + }) + + it('renders /deps nested under the shell, gate degraded to cell (Batch 6)', () => { + const m = router.resolve('/deps') + expect(m.name).toBe('deps') + expect(m.matched.length).toBeGreaterThanOrEqual(2) + expect(m.meta.requiresAuth).toBe(true) + expect(m.meta.requiredAction).toBe('read') + expect(m.meta.requiredResource).toBe('cell') + }) + + it('renders /coverage behind only the auth gate — a self-referential meta page (Batch 6)', () => { + const m = router.resolve('/coverage') + expect(m.name).toBe('coverage') + expect(m.matched.length).toBeGreaterThanOrEqual(2) + expect(m.meta.requiresAuth).toBe(true) + // No PDP resource exists for a dev-tool self-overview — auth gate only. + expect(m.meta.requiredAction).toBeUndefined() + expect(m.meta.requiredResource).toBeUndefined() + }) + + it('renders /groups nested under the shell, gate degraded to cell (Batch 6)', () => { + const m = router.resolve('/groups') + expect(m.name).toBe('groups') + expect(m.matched.length).toBeGreaterThanOrEqual(2) + expect(m.meta.requiresAuth).toBe(true) + expect(m.meta.requiredAction).toBe('read') + expect(m.meta.requiredResource).toBe('cell') + }) }) diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 17226f6..f6f1ec4 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -105,6 +105,44 @@ const routes: RouteRecordRaw[] = [ component: () => import('@gocell/devboard/views/cell-detail'), meta: { requiresAuth: true, requiredAction: 'read', requiredResource: 'cell' }, }, + { + // Build · Contract registry (Batch 6). Read-only; data derived from the + // static CELL_MANIFEST (governance gates / response envelopes are a + // labelled static snapshot). The backend has no `contract` PDP resource + // yet, so the gate degrades to `cell` — same gate as the cells list. + path: 'contracts', + name: 'contracts', + component: () => import('@gocell/devboard/views/contracts'), + meta: { requiresAuth: true, requiredAction: 'read', requiredResource: 'cell' }, + }, + { + // Build · Dependency explorer (Batch 6). Read-only; cell dependency + // graph derived from CELL_MANIFEST.dependsOnCells. No `dep` PDP resource + // exists, so the gate degrades to `cell`. + path: 'deps', + name: 'deps', + component: () => import('@gocell/devboard/views/deps'), + meta: { requiresAuth: true, requiredAction: 'read', requiredResource: 'cell' }, + }, + { + // Meta · Coverage matrix (Batch 6). gocell-web's own implementation + // progress board — static, self-referential, with no backend resource + // to authorize against, so it carries only the auth gate (a PDP + // `requiredAction` would fail-closed against a non-existent resource). + path: 'coverage', + name: 'coverage', + component: () => import('@gocell/devboard/views/coverage'), + meta: { requiresAuth: true }, + }, + { + // Operate · Smart Groups preview (Batch 6). Read-only; static group + // rules with membership computed live from CELL_MANIFEST. Same `cell` + // gate degradation as the other devboard views. + path: 'groups', + name: 'groups', + component: () => import('@gocell/devboard/views/groups'), + meta: { requiresAuth: true, requiredAction: 'read', requiredResource: 'cell' }, + }, ], }, ] diff --git a/packages/core/src/i18n/messages/en-US.ts b/packages/core/src/i18n/messages/en-US.ts index f8af775..3cc3788 100644 --- a/packages/core/src/i18n/messages/en-US.ts +++ b/packages/core/src/i18n/messages/en-US.ts @@ -831,6 +831,235 @@ const enUS = { openFull: 'Open in audit page', }, }, + contracts: { + title: 'Contract registry', + subtitle: 'Platform-wide contract topology (serve / call) derived from CELL_MANIFEST.', + count: '{n} contracts', + search: { + label: 'Search contracts', + placeholder: 'Filter by contract or cell…', + }, + kind: { + all: 'All', + http: 'HTTP', + event: 'Event', + }, + table: { + contract: 'Contract', + kind: 'Kind', + servers: 'Served by', + callers: 'Called by', + }, + empty: 'No matching contracts.', + snapshot: { + label: 'Static snapshot', + note: 'Governance gates and response envelopes are a static snapshot, pending the backend gocell validate --strict output.', + }, + gates: { + title: 'Governance gates', + subtitle: 'Run by gocell validate --strict', + ch01: 'Contract id maps 1:1 to its filesystem path', + ch02: 'Owner cell exists and is not deprecated', + ch03: 'Consumer roles are valid for the contract kind', + ch04: 'Schema $ref resolves and conforms to JSON Schema 2020-12', + ch05: 'Breaking changes carry a waiver', + ch06: 'Typed response set is a bijection with generated Response structs', + new: 'new', + countLabel: '{passed} / {total} passing', + verdict: { + pass: 'pass', + warn: 'warn', + fail: 'fail', + }, + }, + detail: { + empty: 'Select a contract to see details.', + servers: 'Served by', + callers: 'Called by', + none: 'None', + envelope: 'Typed response envelope', + envelopeNone: 'No response envelope snapshot for this contract.', + status: 'Status', + object: 'Response struct', + }, + envelopeKind: { + success: 'Success response', + clientError: 'Client error', + serverError: 'Server error', + }, + }, + deps: { + title: 'Dependencies', + subtitle: 'Cross-cell dependencies derived from CELL_MANIFEST (goda-style read-only explorer).', + count: '{n} cells', + viewsLabel: 'Dependency view switcher', + view: { + list: 'List', + graph: 'Graph', + tree: 'Tree', + matrix: 'Matrix', + }, + list: { + cell: 'Cell', + dependsOn: 'Depends on', + requiredBy: 'Required by', + none: 'None', + }, + matrix: { + caption: 'Cell dependency matrix: rows depend on columns.', + depends: 'depends on', + independent: 'no dependency', + self: 'self', + }, + graph: { + label: 'Cell dependency directed graph', + empty: 'No dependencies to show.', + }, + tree: { + caption: 'Dependency tree: expands downward from top-level consumers.', + leaf: 'no downstream dependency', + }, + detail: { + empty: 'Select a cell to see its dependencies.', + dependsOn: 'Depends on', + requiredBy: 'Required by', + none: 'None', + }, + empty: 'No cells to show.', + }, + coverage: { + eyebrow: 'META · COVERAGE', + title: 'gocell-web ↔ devboard coverage', + subtitle: 'Design routes vs shipped Vue views. Hand-maintained; truth source dev-coverage.jsx.', + kpi: { + shipped: 'Routes shipped', + drafted: 'Routes drafted', + gaps: 'Gaps to close', + outOfScope: 'Out of scope', + }, + legend: { + matched: 'Matched', + partial: 'Partial', + designOnly: 'Design-only', + backendOnly: 'Backend-only', + outOfScope: 'Out of scope', + }, + filter: { + all: 'All', + matched: 'Matched', + partial: 'Partial', + designOnly: 'Design-only', + backendOnly: 'Backend-only', + outOfScope: 'Out of scope', + }, + col: { + capability: 'Capability', + web: 'gocell-web (real)', + design: 'devboard (design)', + }, + status: { + matched: 'Matched', + partial: 'Partially implemented', + designOnly: 'Design-only', + backendOnly: 'Backend-only', + outOfScope: 'Out of scope', + }, + caption: 'gocell-web implementation progress against design coverage.', + none: '—', + section: { + auth: 'Auth & onboarding', + meta: 'Meta', + access: 'Access', + operate: 'Operate', + build: 'Build · DevTools', + observe: 'Observability', + }, + cap: { + login: 'Login', + firstRun: 'First-run setup', + coverage: 'Coverage matrix', + home: 'Health overview', + identities: 'Identities', + policies: 'Policies', + decisions: 'Decisions', + audit: 'Audit log', + config: 'Configuration', + flags: 'Feature flags', + cells: 'Cells list', + cellDetail: 'Cell detail', + groups: 'Smart Groups', + contracts: 'Contract registry', + deps: 'Dependencies', + workflow: 'Workflows', + ai: 'AI Studio', + observe: 'Observability v1', + }, + }, + groups: { + title: 'Smart Groups', + subtitle: 'Rule-based cell groups (preview; membership computed live from CELL_MANIFEST).', + count: '{n} groups', + preview: { + label: 'Preview', + note: 'Preview feature: group rules are static; members are computed live from the rule, with no backend persistence.', + }, + search: { + label: 'Search groups', + placeholder: 'Filter groups…', + }, + listLabel: 'Group list', + empty: 'No matching groups.', + detail: { + empty: 'Select a group to see its rule and members.', + query: 'Rule', + members: 'Members', + memberCount: '{n} members', + noMembers: 'No cell currently satisfies this rule.', + }, + member: { + cell: 'Cell', + domain: 'Domain', + tier: 'Consistency', + }, + field: { + domain: 'Domain', + tier: 'Consistency', + durability: 'Durability', + dependsOnCount: 'Dependency count', + producesCount: 'Produced contracts', + consumesCount: 'Consumed contracts', + }, + op: { + eq: '=', + neq: '≠', + gte: '≥', + lte: '≤', + gt: '>', + lt: '<', + }, + defs: { + access: { + name: 'Access domain cells', + desc: 'All cells in the Access domain.', + }, + strongConsistency: { + name: 'Strong-consistency cells', + desc: 'Cells at consistency level L3.', + }, + foundation: { + name: 'Foundation cells', + desc: 'Root cells that depend on no other cell.', + }, + highFanin: { + name: 'High fan-in cells', + desc: 'Cells consuming five or more contracts.', + }, + durable: { + name: 'Durable cells', + desc: 'Cells whose durability mode is durable.', + }, + }, + }, errors: { unknown: 'An unknown error occurred. Please try again.', network: 'Network connection failed. Please check your connection.', diff --git a/packages/core/src/i18n/messages/zh-CN.ts b/packages/core/src/i18n/messages/zh-CN.ts index 4debd4d..ac17ef3 100644 --- a/packages/core/src/i18n/messages/zh-CN.ts +++ b/packages/core/src/i18n/messages/zh-CN.ts @@ -818,6 +818,235 @@ const zhCN = { openFull: '在审计页打开', }, }, + contracts: { + title: '契约登记', + subtitle: '由 CELL_MANIFEST 派生的全平台契约拓扑(serve / call 关系)。', + count: '{n} 个契约', + search: { + label: '搜索契约', + placeholder: '按契约或 cell 过滤…', + }, + kind: { + all: '全部', + http: 'HTTP', + event: '事件', + }, + table: { + contract: '契约', + kind: '类型', + servers: '提供方', + callers: '调用方', + }, + empty: '无匹配的契约。', + snapshot: { + label: '静态快照', + note: '治理门禁与响应信封为静态快照,待接后端 gocell validate --strict 输出。', + }, + gates: { + title: '治理门禁', + subtitle: '由 gocell validate --strict 执行', + ch01: '契约 id 与文件系统路径一一对应', + ch02: '归属 cell 存在且生命周期未废弃', + ch03: '消费方角色对该契约类型合法', + ch04: 'Schema $ref 可解析且符合 JSON Schema 2020-12', + ch05: '破坏性变更必须附 waiver', + ch06: '类型化响应集与生成的 Response struct 双射', + new: '新', + countLabel: '{passed} / {total} 通过', + verdict: { + pass: '通过', + warn: '告警', + fail: '失败', + }, + }, + detail: { + empty: '选择一个契约查看详情。', + servers: '提供方', + callers: '调用方', + none: '无', + envelope: '类型化响应信封', + envelopeNone: '该契约暂无响应信封快照。', + status: '状态码', + object: '响应结构', + }, + envelopeKind: { + success: '成功响应', + clientError: '客户端错误', + serverError: '服务端错误', + }, + }, + deps: { + title: '依赖', + subtitle: '由 CELL_MANIFEST 派生的 cell 间依赖关系(goda 风格只读浏览)。', + count: '{n} 个 cell', + viewsLabel: '依赖视图切换', + view: { + list: '列表', + graph: '图', + tree: '树', + matrix: '矩阵', + }, + list: { + cell: 'Cell', + dependsOn: '依赖', + requiredBy: '被依赖', + none: '无', + }, + matrix: { + caption: 'cell 依赖矩阵:行依赖列。', + depends: '依赖', + independent: '不依赖', + self: '自身', + }, + graph: { + label: 'cell 依赖有向图', + empty: '无依赖关系可展示。', + }, + tree: { + caption: '依赖树:从顶层消费方向下展开。', + leaf: '无下游依赖', + }, + detail: { + empty: '选择一个 cell 查看其依赖。', + dependsOn: '依赖', + requiredBy: '被依赖', + none: '无', + }, + empty: '无 cell 可展示。', + }, + coverage: { + eyebrow: 'META · 覆盖率', + title: 'gocell-web ↔ devboard 覆盖矩阵', + subtitle: '设计稿路由与已实现 Vue 视图的对照进度。手工维护,真相源 dev-coverage.jsx。', + kpi: { + shipped: '已交付路由', + drafted: '设计稿路由', + gaps: '待补差距', + outOfScope: '范围外', + }, + legend: { + matched: '已对齐', + partial: '部分', + designOnly: '仅设计', + backendOnly: '待后端', + outOfScope: '范围外', + }, + filter: { + all: '全部', + matched: '已对齐', + partial: '部分', + designOnly: '仅设计', + backendOnly: '待后端', + outOfScope: '范围外', + }, + col: { + capability: '能力', + web: 'gocell-web(实现)', + design: 'devboard(设计)', + }, + status: { + matched: '已对齐', + partial: '部分实现', + designOnly: '仅设计稿', + backendOnly: '待后端', + outOfScope: '范围外', + }, + caption: 'gocell-web 实现进度与设计稿覆盖对照矩阵。', + none: '—', + section: { + auth: '认证与引导', + meta: 'Meta', + access: 'Access', + operate: 'Operate', + build: 'Build · DevTools', + observe: '可观测', + }, + cap: { + login: '登录', + firstRun: '首次安装向导', + coverage: '覆盖矩阵', + home: '健康总览', + identities: '身份', + policies: '策略', + decisions: '决策', + audit: '审计日志', + config: '配置', + flags: '功能开关', + cells: 'Cells 列表', + cellDetail: 'Cell 详情', + groups: '智能分组', + contracts: '契约登记', + deps: '依赖浏览', + workflow: '工作流', + ai: 'AI Studio', + observe: '可观测 v1', + }, + }, + groups: { + title: '智能分组', + subtitle: '基于规则的 cell 分组(预览级,成员实时计算自 CELL_MANIFEST)。', + count: '{n} 个分组', + preview: { + label: '预览', + note: '预览能力:分组规则为静态,成员按规则实时计算,无后端持久化。', + }, + search: { + label: '搜索分组', + placeholder: '过滤分组…', + }, + listLabel: '分组列表', + empty: '无匹配的分组。', + detail: { + empty: '选择一个分组查看规则与成员。', + query: '规则', + members: '成员', + memberCount: '{n} 个成员', + noMembers: '当前无 cell 满足该规则。', + }, + member: { + cell: 'Cell', + domain: '域', + tier: '一致性级别', + }, + field: { + domain: '域', + tier: '一致性级别', + durability: '持久性', + dependsOnCount: '依赖数', + producesCount: '提供契约数', + consumesCount: '消费契约数', + }, + op: { + eq: '=', + neq: '≠', + gte: '≥', + lte: '≤', + gt: '>', + lt: '<', + }, + defs: { + access: { + name: 'Access 域 cell', + desc: '归属 Access 域的所有 cell。', + }, + strongConsistency: { + name: '强一致 cell', + desc: '一致性级别为 L3 的 cell。', + }, + foundation: { + name: '基础 cell', + desc: '不依赖任何其他 cell 的根 cell。', + }, + highFanin: { + name: '高扇入 cell', + desc: '消费 5 个及以上契约的 cell。', + }, + durable: { + name: '持久化 cell', + desc: '持久性模式为 durable 的 cell。', + }, + }, + }, errors: { unknown: '发生未知错误,请稍后重试', network: '网络连接失败,请检查网络后重试', diff --git a/packages/devboard/README.md b/packages/devboard/README.md index 4049fd0..23cb975 100644 --- a/packages/devboard/README.md +++ b/packages/devboard/README.md @@ -9,9 +9,26 @@ - `.` → `src/index.ts`:manifest 类型、`CELL_MANIFEST`、`useCellsStore`、共享组件(`CellDurabilityBadge` / `UnavailablePanel`) - `./views/cells-list` → `CellsListView.vue`(`/cells` 列表页,T503) - `./views/cell-detail` → `CellDetailView.vue`(`/cells/:id` 12-tab 详情,T504) +- `./views/contracts` → `ContractsView.vue`(`/contracts` 平台级 contract registry,T601) +- `./views/deps` → `DepsView.vue`(`/deps` cell 依赖 explorer,4 视图 list/graph/tree/matrix,T602) +- `./views/coverage` → `CoverageView.vue`(`/coverage` 实施进度矩阵,T603) +- `./views/groups` → `GroupsView.vue`(`/groups` Smart Groups preview,T604) 未列出的路径外部不可访问(`package.json#exports` 唯一收口)。 +## Batch 6 DevTools 只读页(平台级,区别于 cell-detail 内同名 tab) + +四页全部落本包,零跨包 import、零后端调用、零图形库依赖(graph/tree/matrix 纯 SVG/CSS)。数据分两类,严守 **绝不伪造**: + +| 页面 | 真实派生(自 `CELL_MANIFEST`) | 静态快照(明确标注,待后端) | +|---|---|---| +| `/contracts` | contract registry:聚合所有 cell 的 `produces`/`consumes` → serve/call 关系(`composables/useContractsRegistry.ts`) | governance gates CH-01..06 + typed response envelope(`data/governanceGates.ts` / `data/responseEnvelopes.ts`,页头 `静态快照` banner,待接 `gocell validate --strict`) | +| `/deps` | cell 间依赖图:自 `dependsOnCells`/`requiredByCells` 派生 edges/forest/matrix(`composables/useDepsGraph.ts`) | —(不引入 go mod 包级快照,避免伪造;cell 级依赖随后端真实增长) | +| `/coverage` | —(与 `CELL_MANIFEST` 正交) | 实施进度矩阵(`data/coverageMatrix.ts`,手维护 meta,真相源 `dev-coverage.jsx`,PRD §514) | +| `/groups` | 成员实时计算:predicate 对真实 manifest 字段求值(`data/smartGroups.ts` `groupMembers`) | 分组规则定义(5 条,predicate 仅用可派生字段 domain/tier/durability/依赖数/契约数,preview 级无持久化) | + +PDP 门:后端无 `contract`/`dep`/`group` resource,路由守卫降级到 `requiredResource: 'cell'`(同 `/cells`);`/coverage` 为 dev-tool 自览,仅 `requiresAuth`(设 `requiredAction` 会对不存在的 resource fail-closed)。 + ## Cell manifest 派生(T502,AI-robust Hard) `tools/cell-manifest/` 镜像 `tools/codegen` 模式:构建期读后端 `cell.yaml` + `slice.yaml` → 派生 `src/manifest/cells.generated.ts`(`/* eslint-disable */` + DO-NOT-EDIT banner,prettier-ignored)。 diff --git a/packages/devboard/package.json b/packages/devboard/package.json index 321427c..7d21489 100644 --- a/packages/devboard/package.json +++ b/packages/devboard/package.json @@ -6,7 +6,11 @@ "exports": { ".": "./src/index.ts", "./views/cells-list": "./src/views/CellsListView.vue", - "./views/cell-detail": "./src/views/CellDetailView.vue" + "./views/cell-detail": "./src/views/CellDetailView.vue", + "./views/contracts": "./src/views/ContractsView.vue", + "./views/deps": "./src/views/DepsView.vue", + "./views/coverage": "./src/views/CoverageView.vue", + "./views/groups": "./src/views/GroupsView.vue" }, "files": [ "src" diff --git a/packages/devboard/src/components/contracts/GovernanceGatesPanel.spec.ts b/packages/devboard/src/components/contracts/GovernanceGatesPanel.spec.ts new file mode 100644 index 0000000..b285464 --- /dev/null +++ b/packages/devboard/src/components/contracts/GovernanceGatesPanel.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import GovernanceGatesPanel from './GovernanceGatesPanel.vue' +import { GOVERNANCE_GATES } from '../../data/governanceGates' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (k: string, params?: Record) => { + if (params !== undefined) return `${k}(${JSON.stringify(params)})` + return k + }, + }), +})) + +function mountPanel() { + return mount(GovernanceGatesPanel) +} + +describe('GovernanceGatesPanel', () => { + it('renders exactly 6 gate rows', () => { + const wrapper = mountPanel() + const rows = wrapper.findAll('[data-testid="gate-row"]') + expect(rows.length).toBe(6) + expect(GOVERNANCE_GATES.length).toBe(6) + }) + + it('renders the panel title and subtitle', () => { + const wrapper = mountPanel() + expect(wrapper.text()).toContain('contracts.gates.title') + expect(wrapper.text()).toContain('contracts.gates.subtitle') + }) + + it('every gate row shows the gate id', () => { + const wrapper = mountPanel() + for (const gate of GOVERNANCE_GATES) { + expect(wrapper.text()).toContain(gate.id) + } + }) + + it('every gate verdict badge contains sr-only text (not color-only)', () => { + const wrapper = mountPanel() + const srOnlyEls = wrapper.findAll('.sr-only') + // Each gate has a verdict badge with sr-only text + expect(srOnlyEls.length).toBeGreaterThanOrEqual(GOVERNANCE_GATES.length) + const allSrText = srOnlyEls.map((el) => el.text()) + // Each verdict key should appear: pass, warn, or fail + const verdictKeys = [ + 'contracts.gates.verdict.pass', + 'contracts.gates.verdict.warn', + 'contracts.gates.verdict.fail', + ] + const someVerdictPresent = verdictKeys.some((k) => allSrText.some((t) => t.includes(k))) + expect(someVerdictPresent).toBe(true) + }) + + it('CH-06 gate has the "new" affordance', () => { + const wrapper = mountPanel() + const ch06Row = wrapper + .findAll('[data-testid="gate-row"]') + .find((row) => row.text().includes('CH-06')) + expect(ch06Row).toBeDefined() + expect(ch06Row!.text()).toContain('contracts.gates.new') + }) + + it('renders passed/total counts for each gate', () => { + const wrapper = mountPanel() + for (const gate of GOVERNANCE_GATES) { + expect(wrapper.text()).toContain( + `contracts.gates.countLabel(${JSON.stringify({ passed: gate.passed, total: gate.total })})`, + ) + } + }) + + it('CH-04 has warn verdict badge', () => { + const wrapper = mountPanel() + // CH-04 has verdict: 'warn' + const ch04Row = wrapper + .findAll('[data-testid="gate-row"]') + .find((row) => row.text().includes('CH-04')) + expect(ch04Row).toBeDefined() + // The sr-only text should be 'contracts.gates.verdict.warn' + const srOnly = ch04Row!.find('.sr-only') + expect(srOnly.exists()).toBe(true) + expect(srOnly.text()).toContain('contracts.gates.verdict.warn') + }) + + it('all pass gates have pass verdict sr-only text', () => { + const wrapper = mountPanel() + const passGates = GOVERNANCE_GATES.filter((g) => g.verdict === 'pass') + for (const gate of passGates) { + const row = wrapper + .findAll('[data-testid="gate-row"]') + .find((r) => r.text().includes(gate.id)) + expect(row).toBeDefined() + const srOnly = row!.find('.sr-only') + expect(srOnly.exists()).toBe(true) + expect(srOnly.text()).toContain('contracts.gates.verdict.pass') + } + }) +}) diff --git a/packages/devboard/src/components/contracts/GovernanceGatesPanel.vue b/packages/devboard/src/components/contracts/GovernanceGatesPanel.vue new file mode 100644 index 0000000..7d0eb35 --- /dev/null +++ b/packages/devboard/src/components/contracts/GovernanceGatesPanel.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/packages/devboard/src/components/deps/DepsGraphView.vue b/packages/devboard/src/components/deps/DepsGraphView.vue new file mode 100644 index 0000000..c0c9714 --- /dev/null +++ b/packages/devboard/src/components/deps/DepsGraphView.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/packages/devboard/src/components/deps/DepsListView.vue b/packages/devboard/src/components/deps/DepsListView.vue new file mode 100644 index 0000000..23edb07 --- /dev/null +++ b/packages/devboard/src/components/deps/DepsListView.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/packages/devboard/src/components/deps/DepsMatrixView.vue b/packages/devboard/src/components/deps/DepsMatrixView.vue new file mode 100644 index 0000000..e606ed0 --- /dev/null +++ b/packages/devboard/src/components/deps/DepsMatrixView.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/packages/devboard/src/components/deps/DepsTreeView.vue b/packages/devboard/src/components/deps/DepsTreeView.vue new file mode 100644 index 0000000..54c06fa --- /dev/null +++ b/packages/devboard/src/components/deps/DepsTreeView.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/packages/devboard/src/components/deps/DepsViewBar.spec.ts b/packages/devboard/src/components/deps/DepsViewBar.spec.ts new file mode 100644 index 0000000..6df800a --- /dev/null +++ b/packages/devboard/src/components/deps/DepsViewBar.spec.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import DepsViewBar from './DepsViewBar.vue' +import type { DepsViewId } from './DepsViewBar.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (k: string) => k }), +})) + +const VIEWS: readonly { id: DepsViewId; labelKey: string }[] = [ + { id: 'list', labelKey: 'deps.view.list' }, + { id: 'graph', labelKey: 'deps.view.graph' }, + { id: 'tree', labelKey: 'deps.view.tree' }, + { id: 'matrix', labelKey: 'deps.view.matrix' }, +] as const + +describe('DepsViewBar', () => { + it('renders a tablist with role=tablist', () => { + const wrapper = mount(DepsViewBar, { + props: { views: VIEWS, activeId: 'list' }, + }) + expect(wrapper.find('[role="tablist"]').exists()).toBe(true) + }) + + it('renders 4 buttons with role=tab', () => { + const wrapper = mount(DepsViewBar, { + props: { views: VIEWS, activeId: 'list' }, + }) + const tabs = wrapper.findAll('[role="tab"]') + expect(tabs).toHaveLength(4) + }) + + it('tablist has aria-label from i18n key', () => { + const wrapper = mount(DepsViewBar, { + props: { views: VIEWS, activeId: 'list' }, + }) + expect(wrapper.find('[role="tablist"]').attributes('aria-label')).toBe('deps.viewsLabel') + }) + + it('active tab has aria-selected=true and tabindex=0', () => { + const wrapper = mount(DepsViewBar, { + props: { views: VIEWS, activeId: 'graph' }, + }) + const active = wrapper.find('[aria-selected="true"]') + expect(active.exists()).toBe(true) + expect(active.attributes('tabindex')).toBe('0') + expect(active.attributes('id')).toBe('deps-tab-graph') + }) + + it('inactive tabs have aria-selected=false and tabindex=-1', () => { + const wrapper = mount(DepsViewBar, { + props: { views: VIEWS, activeId: 'list' }, + }) + const inactive = wrapper.findAll('[aria-selected="false"]') + expect(inactive).toHaveLength(3) + for (const btn of inactive) { + expect(btn.attributes('tabindex')).toBe('-1') + } + }) + + it('active tab has aria-controls pointing to its panel id', () => { + const wrapper = mount(DepsViewBar, { + props: { views: VIEWS, activeId: 'tree' }, + }) + const active = wrapper.find('[aria-selected="true"]') + expect(active.attributes('aria-controls')).toBe('deps-panel-tree') + }) + + it('inactive tabs have no aria-controls attribute', () => { + const wrapper = mount(DepsViewBar, { + props: { views: VIEWS, activeId: 'list' }, + }) + const inactive = wrapper.findAll('[aria-selected="false"]') + for (const btn of inactive) { + expect(btn.attributes('aria-controls')).toBeUndefined() + } + }) + + it('click emits select with the view id', async () => { + const wrapper = mount(DepsViewBar, { + props: { views: VIEWS, activeId: 'list' }, + }) + const graphBtn = wrapper.find('#deps-tab-graph') + await graphBtn.trigger('click') + expect(wrapper.emitted('select')).toBeTruthy() + expect(wrapper.emitted('select')?.[0]).toEqual(['graph']) + }) + + it('ArrowRight keydown moves focus to next tab and emits select', async () => { + const wrapper = mount(DepsViewBar, { + props: { views: VIEWS, activeId: 'list' }, + attachTo: document.body, + }) + const listBtn = wrapper.find('#deps-tab-list') + const graphBtn = wrapper.find('#deps-tab-graph') + const focusSpy = vi.spyOn(graphBtn.element as HTMLElement, 'focus') + await listBtn.trigger('keydown', { key: 'ArrowRight' }) + expect(focusSpy).toHaveBeenCalled() + expect(wrapper.emitted('select')?.[0]).toEqual(['graph']) + wrapper.unmount() + }) + + it('ArrowLeft keydown emits select with the previous tab id', async () => { + const wrapper = mount(DepsViewBar, { + props: { views: VIEWS, activeId: 'graph' }, + attachTo: document.body, + }) + const graphBtn = wrapper.find('#deps-tab-graph') + await graphBtn.trigger('keydown', { key: 'ArrowLeft' }) + expect(wrapper.emitted('select')?.[0]).toEqual(['list']) + wrapper.unmount() + }) + + it('ArrowRight wraps around from last to first tab', async () => { + const wrapper = mount(DepsViewBar, { + props: { views: VIEWS, activeId: 'matrix' }, + attachTo: document.body, + }) + const matrixBtn = wrapper.find('#deps-tab-matrix') + await matrixBtn.trigger('keydown', { key: 'ArrowRight' }) + expect(wrapper.emitted('select')?.[0]).toEqual(['list']) + wrapper.unmount() + }) + + it('button text is the labelKey via t()', () => { + const wrapper = mount(DepsViewBar, { + props: { views: VIEWS, activeId: 'list' }, + }) + const firstBtn = wrapper.findAll('[role="tab"]')[0] + expect(firstBtn?.text()).toBe('deps.view.list') + }) +}) diff --git a/packages/devboard/src/components/deps/DepsViewBar.vue b/packages/devboard/src/components/deps/DepsViewBar.vue new file mode 100644 index 0000000..d95d2ee --- /dev/null +++ b/packages/devboard/src/components/deps/DepsViewBar.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/packages/devboard/src/components/deps/DepsViews.spec.ts b/packages/devboard/src/components/deps/DepsViews.spec.ts new file mode 100644 index 0000000..eb975ec --- /dev/null +++ b/packages/devboard/src/components/deps/DepsViews.spec.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import DepsListView from './DepsListView.vue' +import DepsGraphView from './DepsGraphView.vue' +import DepsTreeView from './DepsTreeView.vue' +import DepsMatrixView from './DepsMatrixView.vue' +import type { CellEntry } from '../../manifest/types' +import type { DependencyEdge, DependencyTreeNode } from '../../composables/useDepsGraph' +import { + buildDependencyEdges, + buildDependencyForest, + buildDependsOnIndex, +} from '../../composables/useDepsGraph' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (k: string, params?: Record) => { + if (params !== undefined) return `${k}(${JSON.stringify(params)})` + return k + }, + }), +})) + +const makeCell = (overrides: Partial): CellEntry => ({ + id: 'testcore', + name: 'TestCore', + domain: 'Test', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'TestCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], + ...overrides, +}) + +// 3 cells that mirror the real manifest topology: +// configcore (root) → accesscore → auditcore +const CELLS: readonly CellEntry[] = [ + makeCell({ id: 'configcore', dependsOnCells: [], requiredByCells: ['accesscore', 'auditcore'] }), + makeCell({ id: 'accesscore', dependsOnCells: ['configcore'], requiredByCells: ['auditcore'] }), + makeCell({ + id: 'auditcore', + dependsOnCells: ['accesscore', 'configcore'], + requiredByCells: [], + }), +] + +const EDGES: readonly DependencyEdge[] = buildDependencyEdges(CELLS) +const FOREST: readonly DependencyTreeNode[] = buildDependencyForest(CELLS) +const DEPENDS_ON = buildDependsOnIndex(CELLS) + +// ─── DepsListView ────────────────────────────────────────────────────────── + +describe('DepsListView', () => { + it('renders one table row per cell', () => { + const wrapper = mount(DepsListView, { props: { cells: CELLS } }) + const rows = wrapper.findAll('tbody tr') + expect(rows.length).toBe(3) + }) + + it('all th elements have scope=col', () => { + const wrapper = mount(DepsListView, { props: { cells: CELLS } }) + const headers = wrapper.findAll('th') + expect(headers.length).toBeGreaterThan(0) + for (const th of headers) { + expect(th.attributes('scope')).toBe('col') + } + }) + + it('cell ids appear in mono spans', () => { + const wrapper = mount(DepsListView, { props: { cells: CELLS } }) + expect(wrapper.text()).toContain('configcore') + expect(wrapper.text()).toContain('accesscore') + expect(wrapper.text()).toContain('auditcore') + }) + + it('shows deps.list.none for cells with no dependsOnCells', () => { + const wrapper = mount(DepsListView, { props: { cells: CELLS } }) + // configcore has no dependsOnCells + expect(wrapper.text()).toContain('deps.list.none') + }) + + it('emits select when cell id button clicked', async () => { + const wrapper = mount(DepsListView, { props: { cells: CELLS } }) + const firstBtn = wrapper.find('button') + await firstBtn.trigger('click') + expect(wrapper.emitted('select')).toBeTruthy() + expect(typeof wrapper.emitted('select')?.[0]?.[0]).toBe('string') + }) +}) + +// ─── DepsGraphView ───────────────────────────────────────────────────────── + +describe('DepsGraphView', () => { + it('renders an svg element with role=img', () => { + const wrapper = mount(DepsGraphView, { + props: { cells: CELLS, edges: EDGES, selectedCellId: null }, + }) + const svg = wrapper.find('svg') + expect(svg.exists()).toBe(true) + expect(svg.attributes('role')).toBe('img') + }) + + it('renders at least 3 rect elements (one per cell)', () => { + const wrapper = mount(DepsGraphView, { + props: { cells: CELLS, edges: EDGES, selectedCellId: null }, + }) + const rects = wrapper.findAll('rect') + expect(rects.length).toBeGreaterThanOrEqual(3) + }) + + it('renders exactly as many line elements as there are edges', () => { + const wrapper = mount(DepsGraphView, { + props: { cells: CELLS, edges: EDGES, selectedCellId: null }, + }) + const lines = wrapper.findAll('line') + expect(lines.length).toBe(EDGES.length) + }) + + it('renders 3 node groups (one per cell) with role=button', () => { + const wrapper = mount(DepsGraphView, { + props: { cells: CELLS, edges: EDGES, selectedCellId: null }, + }) + const nodes = wrapper.findAll('[role="button"]') + expect(nodes.length).toBe(3) + }) + + it('emits select when a node is clicked', async () => { + const wrapper = mount(DepsGraphView, { + props: { cells: CELLS, edges: EDGES, selectedCellId: null }, + }) + const firstNode = wrapper.find('[role="button"]') + await firstNode.trigger('click') + expect(wrapper.emitted('select')).toBeTruthy() + }) + + it('shows empty message when cells array is empty', () => { + const wrapper = mount(DepsGraphView, { + props: { cells: [], edges: [], selectedCellId: null }, + }) + expect(wrapper.find('svg').exists()).toBe(false) + expect(wrapper.text()).toContain('deps.graph.empty') + }) + + it('svg has a title element for accessibility', () => { + const wrapper = mount(DepsGraphView, { + props: { cells: CELLS, edges: EDGES, selectedCellId: null }, + }) + const title = wrapper.find('title') + expect(title.exists()).toBe(true) + expect(title.text()).toBe('deps.graph.label') + }) + + it('selected node has aria-pressed=true', () => { + const wrapper = mount(DepsGraphView, { + props: { cells: CELLS, edges: EDGES, selectedCellId: 'accesscore' }, + }) + const pressed = wrapper.find('[aria-pressed="true"]') + expect(pressed.exists()).toBe(true) + expect(pressed.attributes('aria-label')).toBe('accesscore') + }) +}) + +// ─── DepsTreeView ────────────────────────────────────────────────────────── + +describe('DepsTreeView', () => { + it('renders details elements for nodes with children', () => { + const wrapper = mount(DepsTreeView, { props: { forest: FOREST } }) + const details = wrapper.findAll('details') + expect(details.length).toBeGreaterThan(0) + }) + + it('details elements are open by default', () => { + const wrapper = mount(DepsTreeView, { props: { forest: FOREST } }) + const details = wrapper.findAll('details') + for (const d of details) { + expect(d.attributes('open')).toBeDefined() + } + }) + + it('renders cell ids in the tree', () => { + const wrapper = mount(DepsTreeView, { props: { forest: FOREST } }) + expect(wrapper.text()).toContain('auditcore') + expect(wrapper.text()).toContain('accesscore') + expect(wrapper.text()).toContain('configcore') + }) + + it('emits select when a node button is clicked', async () => { + const wrapper = mount(DepsTreeView, { props: { forest: FOREST } }) + const btn = wrapper.find('button') + await btn.trigger('click') + expect(wrapper.emitted('select')).toBeTruthy() + }) + + it('renders caption text from i18n', () => { + const wrapper = mount(DepsTreeView, { props: { forest: FOREST } }) + expect(wrapper.text()).toContain('deps.tree.caption') + }) + + it('leaf nodes have sr-only text', () => { + // configcore is a leaf (its only appearance as a child has no further children in the forest) + const singleLeafCells: readonly CellEntry[] = [ + makeCell({ id: 'configcore', dependsOnCells: [], requiredByCells: [] }), + ] + const singleForest = buildDependencyForest(singleLeafCells) + const wrapper = mount(DepsTreeView, { props: { forest: singleForest } }) + expect(wrapper.find('.sr-only').exists()).toBe(true) + expect(wrapper.find('.sr-only').text()).toBe('deps.tree.leaf') + }) +}) + +// ─── DepsMatrixView ──────────────────────────────────────────────────────── + +describe('DepsMatrixView', () => { + it('renders a table with a caption', () => { + const wrapper = mount(DepsMatrixView, { + props: { cells: CELLS, dependsOn: DEPENDS_ON }, + }) + expect(wrapper.find('table').exists()).toBe(true) + expect(wrapper.find('caption').text()).toBe('deps.matrix.caption') + }) + + it('all col header th elements have scope=col', () => { + const wrapper = mount(DepsMatrixView, { + props: { cells: CELLS, dependsOn: DEPENDS_ON }, + }) + const colHeaders = wrapper.findAll('thead th[scope="col"]') + // 3 data cell columns (corner is a td, not th) + expect(colHeaders.length).toBe(3) + }) + + it('all row header th elements have scope=row', () => { + const wrapper = mount(DepsMatrixView, { + props: { cells: CELLS, dependsOn: DEPENDS_ON }, + }) + const rowHeaders = wrapper.findAll('th[scope="row"]') + expect(rowHeaders.length).toBe(3) + }) + + it('dependency cells have accent-soft background class', () => { + const wrapper = mount(DepsMatrixView, { + props: { cells: CELLS, dependsOn: DEPENDS_ON }, + }) + const dependCells = wrapper.findAll('.deps-matrix__cell--depends') + // auditcore depends on accesscore + configcore = 2 cells + // accesscore depends on configcore = 1 cell + // total = 3 dependency cells + expect(dependCells.length).toBe(3) + }) + + it('dependency cells contain a sr-only deps.matrix.depends text', () => { + const wrapper = mount(DepsMatrixView, { + props: { cells: CELLS, dependsOn: DEPENDS_ON }, + }) + const srTexts = wrapper.findAll('.sr-only').filter((el) => el.text() === 'deps.matrix.depends') + expect(srTexts.length).toBe(3) + }) + + it('self cells (diagonal) have the self class', () => { + const wrapper = mount(DepsMatrixView, { + props: { cells: CELLS, dependsOn: DEPENDS_ON }, + }) + const selfCells = wrapper.findAll('.deps-matrix__cell--self') + expect(selfCells.length).toBe(3) + }) + + it('self cells contain sr-only deps.matrix.self text', () => { + const wrapper = mount(DepsMatrixView, { + props: { cells: CELLS, dependsOn: DEPENDS_ON }, + }) + const srSelf = wrapper.findAll('.sr-only').filter((el) => el.text() === 'deps.matrix.self') + expect(srSelf.length).toBe(3) + }) + + it('independent cells contain sr-only deps.matrix.independent text', () => { + const wrapper = mount(DepsMatrixView, { + props: { cells: CELLS, dependsOn: DEPENDS_ON }, + }) + // 3x3 grid = 9; self=3, deps=3, independent=3 + const srIndep = wrapper + .findAll('.sr-only') + .filter((el) => el.text() === 'deps.matrix.independent') + expect(srIndep.length).toBe(3) + }) + + it('cell ids appear in mono spans in headers', () => { + const wrapper = mount(DepsMatrixView, { + props: { cells: CELLS, dependsOn: DEPENDS_ON }, + }) + expect(wrapper.text()).toContain('configcore') + expect(wrapper.text()).toContain('accesscore') + expect(wrapper.text()).toContain('auditcore') + }) +}) diff --git a/packages/devboard/src/composables/useContractsRegistry.spec.ts b/packages/devboard/src/composables/useContractsRegistry.spec.ts new file mode 100644 index 0000000..550c51f --- /dev/null +++ b/packages/devboard/src/composables/useContractsRegistry.spec.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { buildContractRegistry, contractKind, useContractsRegistry } from './useContractsRegistry' +import type { CellEntry } from '../manifest/types' + +const makeCell = (overrides: Partial): CellEntry => ({ + id: 'testcore', + name: 'TestCore', + domain: 'Test', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'TestCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], + ...overrides, +}) + +const FIXTURE: CellEntry[] = [ + makeCell({ + id: 'configcore', + produces: [ + { contract: 'http.config.get.v1', role: 'serve' }, + { contract: 'event.config.entry-upserted.v1', role: 'publish' }, + ], + consumes: [{ contract: 'event.config.entry-upserted.v1', role: 'subscribe' }], + }), + makeCell({ + id: 'accesscore', + produces: [{ contract: 'event.user.created.v1', role: 'publish' }], + consumes: [{ contract: 'http.config.get.v1', role: 'call' }], + }), + makeCell({ + id: 'auditcore', + produces: [], + consumes: [ + { contract: 'event.user.created.v1', role: 'subscribe' }, + { contract: 'event.config.entry-upserted.v1', role: 'subscribe' }, + ], + }), +] + +describe('contractKind', () => { + it('classifies event.* contracts as event', () => { + expect(contractKind('event.user.created.v1')).toBe('event') + }) + it('classifies http.* contracts as http', () => { + expect(contractKind('http.config.get.v1')).toBe('http') + }) +}) + +describe('buildContractRegistry', () => { + it('aggregates a served contract to its serving cell', () => { + const reg = buildContractRegistry(FIXTURE) + const entry = reg.find((e) => e.contract === 'http.config.get.v1') + expect(entry).toBeDefined() + expect(entry!.servers).toEqual(['configcore']) + expect(entry!.callers).toEqual(['accesscore']) + expect(entry!.kind).toBe('http') + }) + + it('aggregates a published event to its publisher and subscribers', () => { + const reg = buildContractRegistry(FIXTURE) + const entry = reg.find((e) => e.contract === 'event.user.created.v1') + expect(entry!.servers).toEqual(['accesscore']) + expect(entry!.callers).toEqual(['auditcore']) + expect(entry!.kind).toBe('event') + }) + + it('handles a contract both produced and consumed by overlapping cells', () => { + const reg = buildContractRegistry(FIXTURE) + const entry = reg.find((e) => e.contract === 'event.config.entry-upserted.v1') + // configcore both publishes and subscribes; auditcore subscribes + expect(entry!.servers).toEqual(['configcore']) + expect(entry!.callers).toEqual(['auditcore', 'configcore']) + }) + + it('returns entries sorted by contract id', () => { + const reg = buildContractRegistry(FIXTURE) + const ids = reg.map((e) => e.contract) + expect(ids).toEqual([...ids].sort((a, b) => a.localeCompare(b))) + }) + + it('produces exactly one entry per unique contract', () => { + const reg = buildContractRegistry(FIXTURE) + const ids = reg.map((e) => e.contract) + expect(new Set(ids).size).toBe(ids.length) + expect(ids).toHaveLength(3) + }) + + it('returns an empty registry for no cells', () => { + expect(buildContractRegistry([])).toEqual([]) + }) +}) + +describe('useContractsRegistry', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('builds a registry from the real manifest store', () => { + const { registry } = useContractsRegistry() + expect(registry.value.length).toBeGreaterThan(0) + // http.config.get.v1 is served by configcore, called by accesscore in the real manifest + const entry = registry.value.find((e) => e.contract === 'http.config.get.v1') + expect(entry).toBeDefined() + expect(entry!.servers).toContain('configcore') + expect(entry!.callers).toContain('accesscore') + }) + + it('filters by free-text query against contract id', () => { + const { filtered, query } = useContractsRegistry() + const before = filtered.value.length + query.value = 'http.auth.login' + expect(filtered.value.length).toBeLessThan(before) + expect(filtered.value.every((e) => e.contract.includes('http.auth.login'))).toBe(true) + }) + + it('filters by free-text query against a cell id', () => { + const { filtered, query } = useContractsRegistry() + query.value = 'auditcore' + expect(filtered.value.length).toBeGreaterThan(0) + expect( + filtered.value.every( + (e) => e.servers.includes('auditcore') || e.callers.includes('auditcore'), + ), + ).toBe(true) + }) + + it('filters by kind', () => { + const { filtered, kindFilter } = useContractsRegistry() + kindFilter.value = 'event' + expect(filtered.value.every((e) => e.kind === 'event')).toBe(true) + kindFilter.value = 'http' + expect(filtered.value.every((e) => e.kind === 'http')).toBe(true) + }) + + it('selects and resolves a contract for the detail panel', () => { + const { select, selectedEntry, selectedContract } = useContractsRegistry() + expect(selectedEntry.value).toBeNull() + select('http.config.get.v1') + expect(selectedContract.value).toBe('http.config.get.v1') + expect(selectedEntry.value?.contract).toBe('http.config.get.v1') + select(null) + expect(selectedEntry.value).toBeNull() + }) +}) diff --git a/packages/devboard/src/composables/useContractsRegistry.ts b/packages/devboard/src/composables/useContractsRegistry.ts new file mode 100644 index 0000000..eb08630 --- /dev/null +++ b/packages/devboard/src/composables/useContractsRegistry.ts @@ -0,0 +1,124 @@ +import { computed, readonly, ref, type Ref, type ComputedRef } from 'vue' +import { useCellsStore } from '../stores/useCellsStore' +import type { CellEntry } from '../manifest/types' + +/** + * Contract registry — derived view of the platform's contract topology. + * + * The registry is built entirely from the static CELL_MANIFEST: each cell's + * `produces` (role serve|publish) and `consumes` (role call|subscribe) are + * aggregated by contract id into the set of serving cells and calling cells. + * No backend call — this is real, derivable data (governance gates and typed + * response envelopes, which are NOT derivable, live in src/data as labelled + * static snapshots). + */ + +export type ContractKind = 'http' | 'event' +export type ContractKindFilter = ContractKind | 'all' + +export interface ContractRegistryEntry { + readonly contract: string + /** Transport kind, derived from the contract id prefix. */ + readonly kind: ContractKind + /** Cells that serve/publish this contract, sorted by id. */ + readonly servers: readonly string[] + /** Cells that call/subscribe to this contract, sorted by id. */ + readonly callers: readonly string[] +} + +/** Classify a contract by its id prefix (`event.*` → event, else http). */ +export function contractKind(contract: string): ContractKind { + return contract.startsWith('event.') ? 'event' : 'http' +} + +/** + * Aggregate every cell's produces/consumes into one registry entry per + * contract id. Pure function — the single place the derivation lives, so it + * can be unit-tested against fixtures without a Pinia instance. + */ +export function buildContractRegistry(cells: readonly CellEntry[]): ContractRegistryEntry[] { + const servers = new Map>() + const callers = new Map>() + + const add = (m: Map>, contract: string, cellId: string): void => { + let set = m.get(contract) + if (!set) { + set = new Set() + m.set(contract, set) + } + set.add(cellId) + } + + for (const cell of cells) { + for (const usage of cell.produces) add(servers, usage.contract, cell.id) + for (const usage of cell.consumes) add(callers, usage.contract, cell.id) + } + + const all = new Set([...servers.keys(), ...callers.keys()]) + return [...all] + .sort((a, b) => a.localeCompare(b)) + .map((contract) => ({ + contract, + kind: contractKind(contract), + servers: [...(servers.get(contract) ?? [])].sort((a, b) => a.localeCompare(b)), + callers: [...(callers.get(contract) ?? [])].sort((a, b) => a.localeCompare(b)), + })) +} + +export interface UseContractsRegistry { + /** The full registry, sorted by contract id. */ + readonly registry: ComputedRef + /** Free-text search across contract id / server / caller. */ + readonly query: Ref + /** Filter by transport kind. */ + readonly kindFilter: Ref + /** Registry filtered by query + kind. */ + readonly filtered: ComputedRef + /** The contract id currently selected for the detail panel, or null. */ + readonly selectedContract: Readonly> + /** The selected registry entry, or null. */ + readonly selectedEntry: ComputedRef + /** Select (or clear) the detail contract. */ + select(contract: string | null): void +} + +export function useContractsRegistry(): UseContractsRegistry { + const store = useCellsStore() + const registry = computed(() => buildContractRegistry(store.cells)) + + const query = ref('') + const kindFilter = ref('all') + const selectedContract = ref(null) + + const filtered = computed(() => { + const q = query.value.trim().toLowerCase() + return registry.value.filter((entry) => { + if (kindFilter.value !== 'all' && entry.kind !== kindFilter.value) return false + if (q === '') return true + return ( + entry.contract.toLowerCase().includes(q) || + entry.servers.some((s) => s.toLowerCase().includes(q)) || + entry.callers.some((c) => c.toLowerCase().includes(q)) + ) + }) + }) + + const selectedEntry = computed(() => { + if (selectedContract.value === null) return null + return registry.value.find((e) => e.contract === selectedContract.value) ?? null + }) + + function select(contract: string | null): void { + selectedContract.value = contract + } + + return { + registry, + query, + kindFilter, + filtered, + selectedContract: readonly(selectedContract), + selectedEntry, + select, + } +} diff --git a/packages/devboard/src/composables/useDepsGraph.spec.ts b/packages/devboard/src/composables/useDepsGraph.spec.ts new file mode 100644 index 0000000..8c0e311 --- /dev/null +++ b/packages/devboard/src/composables/useDepsGraph.spec.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { + buildDependencyEdges, + buildDependencyForest, + buildDependsOnIndex, + useDepsGraph, +} from './useDepsGraph' +import type { CellEntry } from '../manifest/types' + +const makeCell = (overrides: Partial): CellEntry => ({ + id: 'testcore', + name: 'TestCore', + domain: 'Test', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'TestCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], + ...overrides, +}) + +// configcore (root: nothing depends on it being a leaf) <- accesscore <- auditcore +const FIXTURE: CellEntry[] = [ + makeCell({ id: 'accesscore', dependsOnCells: ['configcore'], requiredByCells: ['auditcore'] }), + makeCell({ + id: 'auditcore', + dependsOnCells: ['accesscore', 'configcore'], + requiredByCells: [], + }), + makeCell({ id: 'configcore', dependsOnCells: [], requiredByCells: ['accesscore', 'auditcore'] }), +] + +describe('buildDependencyEdges', () => { + it('flattens dependsOnCells into a sorted edge list', () => { + expect(buildDependencyEdges(FIXTURE)).toEqual([ + { from: 'accesscore', to: 'configcore' }, + { from: 'auditcore', to: 'accesscore' }, + { from: 'auditcore', to: 'configcore' }, + ]) + }) + + it('returns no edges when no cell has dependencies', () => { + expect(buildDependencyEdges([makeCell({ id: 'solo' })])).toEqual([]) + }) +}) + +describe('buildDependencyForest', () => { + it('roots the forest at cells nothing depends on', () => { + const forest = buildDependencyForest(FIXTURE) + expect(forest).toHaveLength(1) + expect(forest[0]!.id).toBe('auditcore') + }) + + it('nests dependsOnCells as children', () => { + const forest = buildDependencyForest(FIXTURE) + const audit = forest[0]! + expect(audit.children.map((c) => c.id)).toEqual(['accesscore', 'configcore']) + const access = audit.children.find((c) => c.id === 'accesscore')! + expect(access.children.map((c) => c.id)).toEqual(['configcore']) + }) + + it('guards against cycles', () => { + const cyclic: CellEntry[] = [ + makeCell({ id: 'a', dependsOnCells: ['b'], requiredByCells: ['b'] }), + makeCell({ id: 'b', dependsOnCells: ['a'], requiredByCells: ['a'] }), + ] + // No cell has empty requiredByCells → fallback roots = all; must terminate. + const forest = buildDependencyForest(cyclic) + expect(forest.length).toBeGreaterThan(0) + // Depth is bounded — a path never repeats an id, so depth ≤ cells + 1. + const depth = (n: { children: readonly { children: readonly unknown[] }[] }): number => + 1 + Math.max(0, ...n.children.map((c) => depth(c as never))) + expect(depth(forest[0]! as never)).toBeLessThanOrEqual(cyclic.length + 1) + }) +}) + +describe('buildDependsOnIndex', () => { + it('answers dependency membership in O(1)', () => { + const dependsOn = buildDependsOnIndex(FIXTURE) + expect(dependsOn('auditcore', 'accesscore')).toBe(true) + expect(dependsOn('auditcore', 'configcore')).toBe(true) + expect(dependsOn('configcore', 'auditcore')).toBe(false) + expect(dependsOn('accesscore', 'auditcore')).toBe(false) + }) + + it('returns false for unknown cells', () => { + const dependsOn = buildDependsOnIndex(FIXTURE) + expect(dependsOn('ghost', 'configcore')).toBe(false) + }) +}) + +describe('useDepsGraph', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('exposes edges derived from the real manifest', () => { + const { edges } = useDepsGraph() + // Real manifest: accesscore→configcore, auditcore→accesscore, auditcore→configcore + expect(edges.value).toContainEqual({ from: 'accesscore', to: 'configcore' }) + expect(edges.value).toContainEqual({ from: 'auditcore', to: 'configcore' }) + }) + + it('exposes a non-empty cell list', () => { + const { cells } = useDepsGraph() + expect(cells.value.length).toBeGreaterThanOrEqual(3) + }) + + it('selects and resolves a cell', () => { + const { select, selectedCell, selectedCellId } = useDepsGraph() + expect(selectedCell.value).toBeNull() + select('configcore') + expect(selectedCellId.value).toBe('configcore') + expect(selectedCell.value?.id).toBe('configcore') + select(null) + expect(selectedCell.value).toBeNull() + }) +}) diff --git a/packages/devboard/src/composables/useDepsGraph.ts b/packages/devboard/src/composables/useDepsGraph.ts new file mode 100644 index 0000000..821046f --- /dev/null +++ b/packages/devboard/src/composables/useDepsGraph.ts @@ -0,0 +1,112 @@ +import { computed, readonly, ref, type Ref, type ComputedRef } from 'vue' +import { useCellsStore } from '../stores/useCellsStore' +import type { CellEntry } from '../manifest/types' + +/** + * Cell dependency graph — derived from the static CELL_MANIFEST. + * + * `dependsOnCells` is the canonical edge source (cross-cell consume→produce + * resolution done at manifest-generation time); `requiredByCells` is its + * mirror. The four explorer views (list / graph / tree / matrix) all project + * this single edge set — no backend, no `go mod graph`, just the real cell + * topology the manifest already carries. + */ + +export interface DependencyEdge { + /** The dependent cell. */ + readonly from: string + /** The cell being depended upon. */ + readonly to: string +} + +export interface DependencyTreeNode { + readonly id: string + readonly children: readonly DependencyTreeNode[] +} + +/** Flatten every cell's `dependsOnCells` into a sorted edge list. */ +export function buildDependencyEdges(cells: readonly CellEntry[]): DependencyEdge[] { + const edges: DependencyEdge[] = [] + for (const cell of cells) { + for (const to of cell.dependsOnCells) edges.push({ from: cell.id, to }) + } + return edges.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)) +} + +/** + * Build a dependency forest. Roots are cells nothing else depends on + * (`requiredByCells` empty) — the top of the dependency chains; each node's + * children are its `dependsOnCells`. A path set guards against cycles. Falls + * back to all cells as roots if every cell is depended upon (pure cycle). + */ +export function buildDependencyForest(cells: readonly CellEntry[]): DependencyTreeNode[] { + const byId = new Map(cells.map((c) => [c.id, c])) + const rootCells = cells.filter((c) => c.requiredByCells.length === 0) + const roots = rootCells.length > 0 ? rootCells : cells + + const build = (id: string, path: ReadonlySet): DependencyTreeNode => { + const cell = byId.get(id) + if (!cell || path.has(id)) return { id, children: [] } + const next = new Set(path) + next.add(id) + return { + id, + children: [...cell.dependsOnCells] + .sort((a, b) => a.localeCompare(b)) + .map((dep) => build(dep, next)), + } + } + + return roots.map((r) => build(r.id, new Set())).sort((a, b) => a.id.localeCompare(b.id)) +} + +/** + * Build an O(1) `dependsOn(from, to)` predicate for the matrix view. + */ +export function buildDependsOnIndex( + cells: readonly CellEntry[], +): (from: string, to: string) => boolean { + const index = new Map>( + cells.map((c) => [c.id, new Set(c.dependsOnCells)]), + ) + return (from, to) => index.get(from)?.has(to) ?? false +} + +export interface UseDepsGraph { + readonly cells: ComputedRef + readonly edges: ComputedRef + readonly forest: ComputedRef + /** O(1) matrix predicate: does `from` depend on `to`? */ + readonly dependsOn: ComputedRef<(from: string, to: string) => boolean> + readonly selectedCellId: Readonly> + readonly selectedCell: ComputedRef + select(id: string | null): void +} + +export function useDepsGraph(): UseDepsGraph { + const store = useCellsStore() + const cells = computed(() => store.cells) + const edges = computed(() => buildDependencyEdges(store.cells)) + const forest = computed(() => buildDependencyForest(store.cells)) + const dependsOn = computed(() => buildDependsOnIndex(store.cells)) + + const selectedCellId = ref(null) + const selectedCell = computed(() => { + if (selectedCellId.value === null) return null + return store.cells.find((c) => c.id === selectedCellId.value) ?? null + }) + + function select(id: string | null): void { + selectedCellId.value = id + } + + return { + cells, + edges, + forest, + dependsOn, + selectedCellId: readonly(selectedCellId), + selectedCell, + select, + } +} diff --git a/packages/devboard/src/data/coverageMatrix.spec.ts b/packages/devboard/src/data/coverageMatrix.spec.ts new file mode 100644 index 0000000..895209d --- /dev/null +++ b/packages/devboard/src/data/coverageMatrix.spec.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { + COVERAGE_MATRIX, + COVERAGE_STATUSES, + coverageCounts, + type CoverageSection, +} from './coverageMatrix' + +describe('COVERAGE_MATRIX', () => { + it('has at least one section with rows', () => { + expect(COVERAGE_MATRIX.length).toBeGreaterThan(0) + expect(COVERAGE_MATRIX.every((s) => s.rows.length > 0)).toBe(true) + }) + + it('uses only known statuses', () => { + for (const section of COVERAGE_MATRIX) { + for (const row of section.rows) { + expect(COVERAGE_STATUSES).toContain(row.status) + } + } + }) + + it('uses unique route paths', () => { + const routes = COVERAGE_MATRIX.flatMap((s) => s.rows.map((r) => r.route)) + expect(new Set(routes).size).toBe(routes.length) + }) + + it('matched rows have a shipped web ref', () => { + for (const section of COVERAGE_MATRIX) { + for (const row of section.rows) { + if (row.status === 'matched') expect(row.web).not.toBeNull() + } + } + }) + + it('design-only rows have no web ref', () => { + for (const section of COVERAGE_MATRIX) { + for (const row of section.rows) { + if (row.status === 'design-only') expect(row.web).toBeNull() + } + } + }) + + it('references an i18n capabilityKey + section titleKey', () => { + for (const section of COVERAGE_MATRIX) { + expect(section.titleKey).toMatch(/^coverage\.section\./) + for (const row of section.rows) { + expect(row.capabilityKey).toMatch(/^coverage\.cap\./) + } + } + }) +}) + +describe('coverageCounts', () => { + it('totals all rows across sections', () => { + const counts = coverageCounts() + const expectedTotal = COVERAGE_MATRIX.reduce((sum, s) => sum + s.rows.length, 0) + expect(counts.total).toBe(expectedTotal) + }) + + it('per-status counts sum to the total', () => { + const counts = coverageCounts() + const sum = + counts.matched + + counts.partial + + counts['design-only'] + + counts['backend-only'] + + counts['out-of-scope'] + expect(sum).toBe(counts.total) + }) + + it('handles an empty matrix', () => { + const empty: CoverageSection[] = [] + const counts = coverageCounts(empty) + expect(counts.total).toBe(0) + expect(counts.matched).toBe(0) + }) +}) diff --git a/packages/devboard/src/data/coverageMatrix.ts b/packages/devboard/src/data/coverageMatrix.ts new file mode 100644 index 0000000..c76cd41 --- /dev/null +++ b/packages/devboard/src/data/coverageMatrix.ts @@ -0,0 +1,222 @@ +/** + * Coverage matrix — STATIC, hand-maintained meta data. + * + * This page is gocell-web's own implementation-progress board: which design + * routes have shipped as real Vue views vs which remain design-only. It does + * NOT describe backend cells (orthogonal to CELL_MANIFEST) and is curated by + * hand against the router — the legitimate truth source is the design file + * `dev-coverage.jsx` (PRD §514). `route`/`web`/`design` are code & route + * literals (not translated); the capability name + status are i18n keys. + */ + +export type CoverageStatus = 'matched' | 'partial' | 'design-only' | 'backend-only' | 'out-of-scope' + +export interface CoverageRow { + /** Route path literal, e.g. '/login'. */ + readonly route: string + /** i18n key for the capability name. */ + readonly capabilityKey: string + /** Shipped Vue implementation ref (import subpath), or null if not built. */ + readonly web: string | null + /** Design source ref (jsx file), or null if no design. */ + readonly design: string | null + readonly status: CoverageStatus +} + +export interface CoverageSection { + /** i18n key for the section heading. */ + readonly titleKey: string + readonly rows: readonly CoverageRow[] +} + +export const COVERAGE_MATRIX: readonly CoverageSection[] = [ + { + titleKey: 'coverage.section.auth', + rows: [ + { + route: '/login', + capabilityKey: 'coverage.cap.login', + web: '@gocell/access/views/login', + design: 'v1-linear.jsx', + status: 'matched', + }, + { + route: '/first-run-setup', + capabilityKey: 'coverage.cap.firstRun', + web: '@gocell/access/views/first-run', + design: 'first-run-setup.jsx', + status: 'matched', + }, + ], + }, + { + titleKey: 'coverage.section.meta', + rows: [ + { + route: '/coverage', + capabilityKey: 'coverage.cap.coverage', + web: '@gocell/devboard/views/coverage', + design: 'dev-coverage.jsx', + status: 'matched', + }, + { + route: '/', + capabilityKey: 'coverage.cap.home', + web: 'apps/web/views/HomeView.vue', + design: 'dev-observe.jsx', + status: 'partial', + }, + ], + }, + { + titleKey: 'coverage.section.access', + rows: [ + { + route: '/access/identities', + capabilityKey: 'coverage.cap.identities', + web: '@gocell/access/views/identities', + design: 'dev-pages.jsx', + status: 'matched', + }, + { + route: '/access/policies', + capabilityKey: 'coverage.cap.policies', + web: '@gocell/access/views/policies', + design: 'dev-pages.jsx', + status: 'matched', + }, + { + route: '/access/decisions', + capabilityKey: 'coverage.cap.decisions', + web: null, + design: 'dev-extras.jsx', + status: 'design-only', + }, + ], + }, + { + titleKey: 'coverage.section.operate', + rows: [ + { + route: '/audit', + capabilityKey: 'coverage.cap.audit', + web: '@gocell/audit/views/audit', + design: 'dev-audit.jsx', + status: 'matched', + }, + { + route: '/config', + capabilityKey: 'coverage.cap.config', + web: '@gocell/config/views/config', + design: 'dev-pages.jsx', + status: 'matched', + }, + { + route: '/flags', + capabilityKey: 'coverage.cap.flags', + web: '@gocell/config/views/flags', + design: 'dev-pages.jsx', + status: 'matched', + }, + { + route: '/cells', + capabilityKey: 'coverage.cap.cells', + web: '@gocell/devboard/views/cells-list', + design: 'dev-cell.jsx', + status: 'matched', + }, + { + route: '/cells/:id', + capabilityKey: 'coverage.cap.cellDetail', + web: '@gocell/devboard/views/cell-detail', + design: 'dev-cell.jsx', + status: 'matched', + }, + { + route: '/groups', + capabilityKey: 'coverage.cap.groups', + web: '@gocell/devboard/views/groups', + design: 'dev-cell.jsx', + status: 'partial', + }, + ], + }, + { + titleKey: 'coverage.section.build', + rows: [ + { + route: '/contracts', + capabilityKey: 'coverage.cap.contracts', + web: '@gocell/devboard/views/contracts', + design: 'dev-extras3.jsx', + status: 'matched', + }, + { + route: '/deps', + capabilityKey: 'coverage.cap.deps', + web: '@gocell/devboard/views/deps', + design: 'dev-extras3.jsx', + status: 'partial', + }, + { + route: '/workflow', + capabilityKey: 'coverage.cap.workflow', + web: null, + design: 'dev-develop.jsx', + status: 'design-only', + }, + { + route: '/ai', + capabilityKey: 'coverage.cap.ai', + web: null, + design: 'dev-wave4.jsx', + status: 'design-only', + }, + ], + }, + { + titleKey: 'coverage.section.observe', + rows: [ + { + route: '/observe', + capabilityKey: 'coverage.cap.observe', + web: null, + design: 'dev-observe.jsx', + status: 'backend-only', + }, + ], + }, +] + +export type CoverageCounts = Readonly> & { readonly total: number } + +const STATUSES: readonly CoverageStatus[] = [ + 'matched', + 'partial', + 'design-only', + 'backend-only', + 'out-of-scope', +] + +/** Tally rows by status across all sections. */ +export function coverageCounts( + matrix: readonly CoverageSection[] = COVERAGE_MATRIX, +): CoverageCounts { + const counts: Record = { + matched: 0, + partial: 0, + 'design-only': 0, + 'backend-only': 0, + 'out-of-scope': 0, + } + let total = 0 + for (const section of matrix) { + for (const row of section.rows) { + counts[row.status] += 1 + total += 1 + } + } + return { ...counts, total } +} + +export { STATUSES as COVERAGE_STATUSES } diff --git a/packages/devboard/src/data/governanceGates.spec.ts b/packages/devboard/src/data/governanceGates.spec.ts new file mode 100644 index 0000000..52fdc7d --- /dev/null +++ b/packages/devboard/src/data/governanceGates.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { GOVERNANCE_GATES } from './governanceGates' + +describe('GOVERNANCE_GATES snapshot', () => { + it('covers CH-01 through CH-06', () => { + expect(GOVERNANCE_GATES.map((g) => g.id)).toEqual([ + 'CH-01', + 'CH-02', + 'CH-03', + 'CH-04', + 'CH-05', + 'CH-06', + ]) + }) + + it('has unique gate ids', () => { + const ids = GOVERNANCE_GATES.map((g) => g.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('uses only valid verdicts and passed ≤ total', () => { + for (const gate of GOVERNANCE_GATES) { + expect(['pass', 'warn', 'fail']).toContain(gate.verdict) + expect(gate.passed).toBeLessThanOrEqual(gate.total) + expect(gate.passed).toBeGreaterThanOrEqual(0) + } + }) + + it('marks only CH-06 as new', () => { + const newOnes = GOVERNANCE_GATES.filter((g) => g.isNew).map((g) => g.id) + expect(newOnes).toEqual(['CH-06']) + }) + + it('references an i18n descKey for every gate', () => { + for (const gate of GOVERNANCE_GATES) { + expect(gate.descKey).toMatch(/^contracts\.gates\./) + } + }) +}) diff --git a/packages/devboard/src/data/governanceGates.ts b/packages/devboard/src/data/governanceGates.ts new file mode 100644 index 0000000..022a02d --- /dev/null +++ b/packages/devboard/src/data/governanceGates.ts @@ -0,0 +1,39 @@ +/** + * Governance gates — STATIC SNAPSHOT. + * + * The CH-01..CH-06 contract-health gates are produced by the backend + * `gocell validate --strict` pipeline; there is no HTTP endpoint exposing + * their live verdicts yet. This is a labelled snapshot for the /contracts + * page — the UI must render it under a visible "snapshot" affordance and must + * NOT present it as a live result. Descriptions are i18n keys (zero hardcode). + */ + +export type GateVerdict = 'pass' | 'warn' | 'fail' + +export interface GovernanceGate { + /** Gate id, e.g. 'CH-01'. */ + readonly id: string + /** i18n key for the human description of what the gate checks. */ + readonly descKey: string + readonly verdict: GateVerdict + readonly passed: number + readonly total: number + /** Recently-introduced gate (renders a "new" affordance). */ + readonly isNew?: boolean +} + +export const GOVERNANCE_GATES: readonly GovernanceGate[] = [ + { id: 'CH-01', descKey: 'contracts.gates.ch01', verdict: 'pass', passed: 45, total: 45 }, + { id: 'CH-02', descKey: 'contracts.gates.ch02', verdict: 'pass', passed: 45, total: 45 }, + { id: 'CH-03', descKey: 'contracts.gates.ch03', verdict: 'pass', passed: 45, total: 45 }, + { id: 'CH-04', descKey: 'contracts.gates.ch04', verdict: 'warn', passed: 44, total: 45 }, + { id: 'CH-05', descKey: 'contracts.gates.ch05', verdict: 'pass', passed: 45, total: 45 }, + { + id: 'CH-06', + descKey: 'contracts.gates.ch06', + verdict: 'pass', + passed: 45, + total: 45, + isNew: true, + }, +] as const diff --git a/packages/devboard/src/data/responseEnvelopes.spec.ts b/packages/devboard/src/data/responseEnvelopes.spec.ts new file mode 100644 index 0000000..a21a371 --- /dev/null +++ b/packages/devboard/src/data/responseEnvelopes.spec.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { RESPONSE_ENVELOPES, responseEnvelopeFor } from './responseEnvelopes' + +describe('RESPONSE_ENVELOPES snapshot', () => { + it('keys are http.* contract ids', () => { + for (const key of Object.keys(RESPONSE_ENVELOPES)) { + expect(key).toMatch(/^http\./) + } + }) + + it('every entry has a valid kind matching its status class', () => { + for (const entries of Object.values(RESPONSE_ENVELOPES)) { + for (const e of entries) { + expect(['2xx', '4xx', '5xx']).toContain(e.kind) + const cls = `${Math.floor(e.status / 100)}xx` + expect(e.kind).toBe(cls) + } + } + }) + + it('each contract has exactly one 2xx success entry', () => { + for (const entries of Object.values(RESPONSE_ENVELOPES)) { + expect(entries.filter((e) => e.kind === '2xx')).toHaveLength(1) + } + }) +}) + +describe('responseEnvelopeFor', () => { + it('returns entries for a known contract', () => { + const entries = responseEnvelopeFor('http.auth.login.v1') + expect(entries).not.toBeNull() + expect(entries!.length).toBeGreaterThan(0) + }) + + it('returns null for a contract without a snapshot', () => { + expect(responseEnvelopeFor('event.user.created.v1')).toBeNull() + }) +}) diff --git a/packages/devboard/src/data/responseEnvelopes.ts b/packages/devboard/src/data/responseEnvelopes.ts new file mode 100644 index 0000000..9deeffe --- /dev/null +++ b/packages/devboard/src/data/responseEnvelopes.ts @@ -0,0 +1,52 @@ +/** + * Typed response envelopes — STATIC SNAPSHOT. + * + * Each HTTP contract's response set (status → generated response struct) is + * declared in the backend contract.yaml and surfaced by `gocell validate + * --strict` (gate CH-06 enforces the 1:1 bijection). No HTTP endpoint exposes + * it yet, so this is a labelled snapshot covering a representative subset of + * the real contract ids in CELL_MANIFEST. Struct names are code identifiers + * (not translated); the kind drives the semantic colour + screen-reader text. + */ + +export type EnvelopeKind = '2xx' | '4xx' | '5xx' + +export interface ResponseEnvelopeEntry { + readonly status: number + /** Generated response struct name (code identifier — not translated). */ + readonly object: string + readonly kind: EnvelopeKind +} + +/** contractId → ordered response entries. */ +export const RESPONSE_ENVELOPES: Readonly> = { + 'http.auth.login.v1': [ + { status: 200, object: 'LoginResponse', kind: '2xx' }, + { status: 400, object: 'Login400ErrorResponse', kind: '4xx' }, + { status: 401, object: 'Login401ErrorResponse', kind: '4xx' }, + { status: 429, object: 'Login429ErrorResponse', kind: '4xx' }, + ], + 'http.auth.user.create.v1': [ + { status: 201, object: 'UserCreateResponse', kind: '2xx' }, + { status: 400, object: 'UserCreate400ErrorResponse', kind: '4xx' }, + { status: 409, object: 'UserCreate409ErrorResponse', kind: '4xx' }, + ], + 'http.audit.list.v1': [ + { status: 200, object: 'AuditListResponse', kind: '2xx' }, + { status: 400, object: 'AuditList400ErrorResponse', kind: '4xx' }, + { status: 500, object: 'AuditList500ErrorResponse', kind: '5xx' }, + ], + 'http.config.get.v1': [ + { status: 200, object: 'ConfigGetResponse', kind: '2xx' }, + { status: 404, object: 'ConfigGet404ErrorResponse', kind: '4xx' }, + ], + 'http.config.publish.v1': [ + { status: 200, object: 'ConfigPublishResponse', kind: '2xx' }, + { status: 409, object: 'ConfigPublish409ErrorResponse', kind: '4xx' }, + ], +} as const + +/** Response envelope entries for a contract, or null if no snapshot exists. */ +export function responseEnvelopeFor(contract: string): readonly ResponseEnvelopeEntry[] | null { + return RESPONSE_ENVELOPES[contract] ?? null +} diff --git a/packages/devboard/src/data/smartGroups.spec.ts b/packages/devboard/src/data/smartGroups.spec.ts new file mode 100644 index 0000000..f0a281b --- /dev/null +++ b/packages/devboard/src/data/smartGroups.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest' +import { SMART_GROUPS, cellFieldValue, evalPredicate, groupMembers } from './smartGroups' +import type { CellEntry } from '../manifest/types' + +const makeCell = (overrides: Partial): CellEntry => ({ + id: 'testcore', + name: 'TestCore', + domain: 'Test', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'TestCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], + ...overrides, +}) + +const ACCESS = makeCell({ + id: 'accesscore', + domain: 'Access', + consistencyLevel: 'L3', + dependsOnCells: ['configcore'], + produces: [{ contract: 'http.auth.login.v1', role: 'serve' }], + consumes: [ + { contract: 'http.config.get.v1', role: 'call' }, + { contract: 'event.role.assigned.v1', role: 'subscribe' }, + { contract: 'event.role.revoked.v1', role: 'subscribe' }, + { contract: 'event.config.entry-deleted.v1', role: 'subscribe' }, + { contract: 'event.config.entry-upserted.v1', role: 'subscribe' }, + ], +}) +const CONFIG = makeCell({ + id: 'configcore', + domain: 'Config', + consistencyLevel: 'L3', + durabilityMode: 'durable', + dependsOnCells: [], +}) +const AUDIT = makeCell({ + id: 'auditcore', + domain: 'Audit', + consistencyLevel: 'L2', + dependsOnCells: ['accesscore', 'configcore'], +}) + +const CELLS = [ACCESS, CONFIG, AUDIT] + +describe('cellFieldValue', () => { + it('maps tier to consistencyLevel', () => { + expect(cellFieldValue(ACCESS, 'tier')).toBe('L3') + }) + it('maps dependsOnCount to dependsOnCells length', () => { + expect(cellFieldValue(ACCESS, 'dependsOnCount')).toBe(1) + expect(cellFieldValue(CONFIG, 'dependsOnCount')).toBe(0) + }) + it('maps consumesCount to consumes length', () => { + expect(cellFieldValue(ACCESS, 'consumesCount')).toBe(5) + }) + it('maps domain and durability directly', () => { + expect(cellFieldValue(ACCESS, 'domain')).toBe('Access') + expect(cellFieldValue(CONFIG, 'durability')).toBe('durable') + }) +}) + +describe('evalPredicate', () => { + it('eq matches string fields', () => { + expect(evalPredicate(ACCESS, { field: 'domain', op: 'eq', value: 'Access' })).toBe(true) + expect(evalPredicate(AUDIT, { field: 'domain', op: 'eq', value: 'Access' })).toBe(false) + }) + it('gte matches numeric fields', () => { + expect(evalPredicate(ACCESS, { field: 'consumesCount', op: 'gte', value: 5 })).toBe(true) + expect(evalPredicate(AUDIT, { field: 'consumesCount', op: 'gte', value: 5 })).toBe(false) + }) + it('numeric ops return false against non-numeric fields', () => { + expect(evalPredicate(ACCESS, { field: 'domain', op: 'gte', value: 5 })).toBe(false) + }) + it('neq inverts eq', () => { + expect(evalPredicate(AUDIT, { field: 'domain', op: 'neq', value: 'Access' })).toBe(true) + }) +}) + +describe('groupMembers', () => { + it('returns cells satisfying all predicates (real membership)', () => { + const access = SMART_GROUPS.find((g) => g.id === 'sg-access')! + expect(groupMembers(access, CELLS).map((c) => c.id)).toEqual(['accesscore']) + }) + + it('foundation group selects cells with no dependencies', () => { + const foundation = SMART_GROUPS.find((g) => g.id === 'sg-foundation')! + expect(groupMembers(foundation, CELLS).map((c) => c.id)).toEqual(['configcore']) + }) + + it('strong-consistency group selects L3 cells', () => { + const strong = SMART_GROUPS.find((g) => g.id === 'sg-strong-consistency')! + expect( + groupMembers(strong, CELLS) + .map((c) => c.id) + .sort(), + ).toEqual(['accesscore', 'configcore']) + }) +}) + +describe('SMART_GROUPS definitions', () => { + it('have unique ids and i18n name/desc keys', () => { + const ids = SMART_GROUPS.map((g) => g.id) + expect(new Set(ids).size).toBe(ids.length) + for (const g of SMART_GROUPS) { + expect(g.nameKey).toMatch(/^groups\.defs\./) + expect(g.descKey).toMatch(/^groups\.defs\./) + expect(g.predicates.length).toBeGreaterThan(0) + } + }) +}) diff --git a/packages/devboard/src/data/smartGroups.ts b/packages/devboard/src/data/smartGroups.ts new file mode 100644 index 0000000..d7a2c67 --- /dev/null +++ b/packages/devboard/src/data/smartGroups.ts @@ -0,0 +1,114 @@ +/** + * Smart Groups — STATIC group definitions, REAL membership. + * + * Preview-level feature: the group rules (predicate sets) are hand-authored + * here, but membership is evaluated live against the real CELL_MANIFEST using + * only derivable cell fields — no fabricated runtime metrics (QPS / health / + * p95). This keeps the preview honest: every member shown is a real cell that + * actually satisfies the predicate. Names/descriptions are i18n keys. + */ + +import type { CellEntry } from '../manifest/types' + +/** Cell fields a predicate can match against — all derivable from the manifest. */ +export type CellField = + | 'domain' + | 'tier' + | 'durability' + | 'dependsOnCount' + | 'producesCount' + | 'consumesCount' + +export type PredicateOp = 'eq' | 'neq' | 'gte' | 'lte' | 'gt' | 'lt' + +export interface Predicate { + readonly field: CellField + readonly op: PredicateOp + readonly value: string | number +} + +export interface SmartGroup { + readonly id: string + /** i18n key for the group name. */ + readonly nameKey: string + /** i18n key for the group description. */ + readonly descKey: string + /** AND-combined predicates. */ + readonly predicates: readonly Predicate[] +} + +export const SMART_GROUPS: readonly SmartGroup[] = [ + { + id: 'sg-access', + nameKey: 'groups.defs.access.name', + descKey: 'groups.defs.access.desc', + predicates: [{ field: 'domain', op: 'eq', value: 'Access' }], + }, + { + id: 'sg-strong-consistency', + nameKey: 'groups.defs.strongConsistency.name', + descKey: 'groups.defs.strongConsistency.desc', + predicates: [{ field: 'tier', op: 'eq', value: 'L3' }], + }, + { + id: 'sg-foundation', + nameKey: 'groups.defs.foundation.name', + descKey: 'groups.defs.foundation.desc', + predicates: [{ field: 'dependsOnCount', op: 'eq', value: 0 }], + }, + { + id: 'sg-high-fanin', + nameKey: 'groups.defs.highFanin.name', + descKey: 'groups.defs.highFanin.desc', + predicates: [{ field: 'consumesCount', op: 'gte', value: 5 }], + }, + { + id: 'sg-durable', + nameKey: 'groups.defs.durable.name', + descKey: 'groups.defs.durable.desc', + predicates: [{ field: 'durability', op: 'eq', value: 'durable' }], + }, +] + +/** Resolve a derivable field value for a cell. */ +export function cellFieldValue(cell: CellEntry, field: CellField): string | number { + switch (field) { + case 'domain': + return cell.domain + case 'tier': + return cell.consistencyLevel + case 'durability': + return cell.durabilityMode + case 'dependsOnCount': + return cell.dependsOnCells.length + case 'producesCount': + return cell.produces.length + case 'consumesCount': + return cell.consumes.length + } +} + +/** Evaluate a single predicate against a cell. */ +export function evalPredicate(cell: CellEntry, predicate: Predicate): boolean { + const actual = cellFieldValue(cell, predicate.field) + const { op, value } = predicate + switch (op) { + case 'eq': + return actual === value + case 'neq': + return actual !== value + case 'gte': + return typeof actual === 'number' && typeof value === 'number' && actual >= value + case 'lte': + return typeof actual === 'number' && typeof value === 'number' && actual <= value + case 'gt': + return typeof actual === 'number' && typeof value === 'number' && actual > value + case 'lt': + return typeof actual === 'number' && typeof value === 'number' && actual < value + } +} + +/** Cells that satisfy ALL of a group's predicates (real membership). */ +export function groupMembers(group: SmartGroup, cells: readonly CellEntry[]): CellEntry[] { + return cells.filter((cell) => group.predicates.every((p) => evalPredicate(cell, p))) +} diff --git a/packages/devboard/src/views/ContractsView.spec.ts b/packages/devboard/src/views/ContractsView.spec.ts new file mode 100644 index 0000000..2b999e4 --- /dev/null +++ b/packages/devboard/src/views/ContractsView.spec.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { defineComponent } from 'vue' +import ContractsView from './ContractsView.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (k: string, params?: Record) => { + if (params !== undefined) return `${k}(${JSON.stringify(params)})` + return k + }, + }), +})) + +// Stub RouterLink +const RouterLinkStub = defineComponent({ + name: 'RouterLink', + props: { to: { type: String, required: true } }, + template: '', +}) + +// Stub GovernanceGatesPanel so we can test its presence without testing its internals +const GovernanceGatesPanelStub = defineComponent({ + name: 'GovernanceGatesPanel', + template: '
', +}) + +function mountView() { + return mount(ContractsView, { + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + stubActions: false, + }), + ], + stubs: { + RouterLink: RouterLinkStub, + GovernanceGatesPanel: GovernanceGatesPanelStub, + }, + }, + }) +} + +describe('ContractsView', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders table rows equal to the registry length', () => { + const wrapper = mountView() + const rows = wrapper.findAll('tbody tr') + // The real manifest has contracts; rows should be > 0 + expect(rows.length).toBeGreaterThan(0) + // The count badge in the header should reflect the registry count + expect(wrapper.text()).toContain('contracts.count') + }) + + it('thead th elements all have scope="col"', () => { + const wrapper = mountView() + const headers = wrapper.findAll('thead th') + expect(headers.length).toBeGreaterThan(0) + headers.forEach((th) => { + expect(th.attributes('scope')).toBe('col') + }) + }) + + it('search input has an associated label (for/id pair)', () => { + const wrapper = mountView() + const input = wrapper.find('input[type="search"]') + expect(input.exists()).toBe(true) + const inputId = input.attributes('id') + expect(inputId).toBeTruthy() + const label = wrapper.find(`label[for="${inputId}"]`) + expect(label.exists()).toBe(true) + }) + + it('typing a query filters the displayed rows', async () => { + const wrapper = mountView() + const allRows = wrapper.findAll('tbody tr') + const totalRows = allRows.length + expect(totalRows).toBeGreaterThan(1) + + const input = wrapper.find('input[type="search"]') + await input.setValue('http.auth.login') + const filteredRows = wrapper.findAll('tbody tr') + expect(filteredRows.length).toBeLessThan(totalRows) + }) + + it('shows empty state when query has no matches', async () => { + const wrapper = mountView() + const input = wrapper.find('input[type="search"]') + await input.setValue('zzz-no-match-at-all') + const rows = wrapper.findAll('tbody tr') + expect(rows.length).toBe(1) + expect(wrapper.text()).toContain('contracts.empty') + }) + + it('clicking a row shows the detail aside with that contract', async () => { + const wrapper = mountView() + // Initially no detail aside visible + expect(wrapper.find('[data-testid="contract-detail"]').exists()).toBe(false) + // Click the first data row + const firstRow = wrapper.find('tbody tr') + await firstRow.trigger('click') + // Detail aside should now appear + const detail = wrapper.find('[data-testid="contract-detail"]') + expect(detail.exists()).toBe(true) + }) + + it('snapshot banner exists with role="note"', () => { + const wrapper = mountView() + const banner = wrapper.find('[role="note"]') + expect(banner.exists()).toBe(true) + expect(banner.text()).toContain('contracts.snapshot.note') + }) + + it('contains the GovernanceGatesPanel component', () => { + const wrapper = mountView() + const panel = wrapper.find('[data-testid="governance-gates-panel"]') + expect(panel.exists()).toBe(true) + }) + + it('kind filter buttons have role="group" container', () => { + const wrapper = mountView() + const group = wrapper.find('[role="group"]') + expect(group.exists()).toBe(true) + const buttons = group.findAll('button') + expect(buttons.length).toBe(3) // all, http, event + }) + + it('kind filter buttons have aria-pressed attributes', () => { + const wrapper = mountView() + const group = wrapper.find('[role="group"]') + const buttons = group.findAll('button') + buttons.forEach((btn) => { + expect(btn.attributes('aria-pressed')).toMatch(/^true|false$/) + }) + }) + + it('"all" filter button is aria-pressed=true initially', () => { + const wrapper = mountView() + const group = wrapper.find('[role="group"]') + const allBtn = group.findAll('button').find((btn) => btn.text().includes('contracts.kind.all')) + expect(allBtn).toBeDefined() + expect(allBtn!.attributes('aria-pressed')).toBe('true') + }) + + it('detail aside shows "empty" placeholder when no contract selected', () => { + const wrapper = mountView() + // Since nothing is selected by default, detail placeholder should say empty + expect(wrapper.text()).toContain('contracts.detail.empty') + }) + + it('clicking a row sets aria-selected on that row', async () => { + const wrapper = mountView() + const firstRow = wrapper.find('tbody tr') + await firstRow.trigger('click') + expect(firstRow.attributes('aria-selected')).toBe('true') + }) + + it('table contains expected column headers', () => { + const wrapper = mountView() + const text = wrapper.text() + expect(text).toContain('contracts.table.contract') + expect(text).toContain('contracts.table.kind') + expect(text).toContain('contracts.table.servers') + expect(text).toContain('contracts.table.callers') + }) +}) diff --git a/packages/devboard/src/views/ContractsView.vue b/packages/devboard/src/views/ContractsView.vue new file mode 100644 index 0000000..952cc60 --- /dev/null +++ b/packages/devboard/src/views/ContractsView.vue @@ -0,0 +1,603 @@ + + + + + diff --git a/packages/devboard/src/views/CoverageView.spec.ts b/packages/devboard/src/views/CoverageView.spec.ts new file mode 100644 index 0000000..afaa311 --- /dev/null +++ b/packages/devboard/src/views/CoverageView.spec.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import CoverageView from './CoverageView.vue' +import { COVERAGE_MATRIX, coverageCounts } from '../data/coverageMatrix' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (k: string, params?: Record) => { + if (params !== undefined) return `${k}(${JSON.stringify(params)})` + return k + }, + }), +})) + +function mountView() { + return mount(CoverageView, { + global: {}, + }) +} + +// Compute expected totals from the data layer +const TOTAL_ROWS = COVERAGE_MATRIX.reduce((sum, s) => sum + s.rows.length, 0) +const SECTION_COUNT = COVERAGE_MATRIX.length +const COUNTS = coverageCounts() +const SHIPPED = COVERAGE_MATRIX.flatMap((s) => s.rows).filter((r) => r.web !== null).length +const DRAFTED = COVERAGE_MATRIX.flatMap((s) => s.rows).filter((r) => r.design !== null).length + +describe('CoverageView', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ── Structure ───────────────────────────────────────────────────────────── + + it('renders all section headings', () => { + const wrapper = mountView() + const text = wrapper.text() + COVERAGE_MATRIX.forEach((section) => { + expect(text).toContain(section.titleKey) + }) + }) + + it('renders total row count matching COVERAGE_MATRIX', () => { + const wrapper = mountView() + // All data rows: tbody tr elements (excluding section header rows) + const dataRows = wrapper.findAll('tr[data-row]') + expect(dataRows.length).toBe(TOTAL_ROWS) + }) + + it('renders header with eyebrow, h1, subtitle', () => { + const wrapper = mountView() + const text = wrapper.text() + expect(text).toContain('coverage.eyebrow') + expect(text).toContain('coverage.title') + expect(text).toContain('coverage.subtitle') + }) + + // ── Table accessibility ─────────────────────────────────────────────────── + + it('thead th elements have scope="col"', () => { + const wrapper = mountView() + const headers = wrapper.findAll('thead th[scope="col"]') + expect(headers.length).toBeGreaterThanOrEqual(3) + }) + + it('table has a caption or aria-label', () => { + const wrapper = mountView() + const caption = wrapper.find('caption') + const tableWithLabel = wrapper.find('table[aria-label]') + // At least one of caption or aria-label must be present + expect(caption.exists() || tableWithLabel.exists()).toBe(true) + }) + + it('thead contains col headers for capability, web, and design', () => { + const wrapper = mountView() + const text = wrapper.text() + expect(text).toContain('coverage.col.capability') + expect(text).toContain('coverage.col.web') + expect(text).toContain('coverage.col.design') + }) + + // ── Status marks & sr-only ──────────────────────────────────────────────── + + it('each status has at least one sr-only text element with coverage.status.* key', () => { + const wrapper = mountView() + const srOnly = wrapper.findAll('.sr-only') + const srTexts = srOnly.map((el) => el.text()) + // We must have sr-only text for status labels — check that at least + // one of the status key patterns appears + const hasStatusText = srTexts.some((t) => t.startsWith('coverage.status.')) + expect(hasStatusText).toBe(true) + }) + + it('matched status renders sr-only text with coverage.status.matched', () => { + const wrapper = mountView() + const srOnly = wrapper.findAll('.sr-only') + const srTexts = srOnly.map((el) => el.text()) + expect(srTexts).toContain('coverage.status.matched') + }) + + it('designOnly status renders sr-only text with coverage.status.designOnly', () => { + const wrapper = mountView() + const srOnly = wrapper.findAll('.sr-only') + const srTexts = srOnly.map((el) => el.text()) + expect(srTexts).toContain('coverage.status.designOnly') + }) + + // ── Legend ──────────────────────────────────────────────────────────────── + + it('renders legend with all 5 status entries', () => { + const wrapper = mountView() + const text = wrapper.text() + expect(text).toContain('coverage.legend.matched') + expect(text).toContain('coverage.legend.partial') + expect(text).toContain('coverage.legend.designOnly') + expect(text).toContain('coverage.legend.backendOnly') + expect(text).toContain('coverage.legend.outOfScope') + }) + + // ── KPI strip ───────────────────────────────────────────────────────────── + + it('renders KPI labels for shipped, drafted, gaps, outOfScope', () => { + const wrapper = mountView() + const text = wrapper.text() + expect(text).toContain('coverage.kpi.shipped') + expect(text).toContain('coverage.kpi.drafted') + expect(text).toContain('coverage.kpi.gaps') + expect(text).toContain('coverage.kpi.outOfScope') + }) + + it('KPI shipped number matches rows with non-null web', () => { + const wrapper = mountView() + const kpi = wrapper.find('[data-testid="kpi-shipped"]') + expect(kpi.exists()).toBe(true) + expect(kpi.text()).toContain(String(SHIPPED)) + }) + + it('KPI drafted number matches rows with non-null design', () => { + const wrapper = mountView() + const kpi = wrapper.find('[data-testid="kpi-drafted"]') + expect(kpi.exists()).toBe(true) + expect(kpi.text()).toContain(String(DRAFTED)) + }) + + it('KPI gaps = total - matched', () => { + const wrapper = mountView() + const kpi = wrapper.find('[data-testid="kpi-gaps"]') + expect(kpi.exists()).toBe(true) + const expectedGaps = COUNTS.total - COUNTS.matched + expect(kpi.text()).toContain(String(expectedGaps)) + }) + + it('KPI outOfScope matches counts', () => { + const wrapper = mountView() + const kpi = wrapper.find('[data-testid="kpi-out-of-scope"]') + expect(kpi.exists()).toBe(true) + expect(kpi.text()).toContain(String(COUNTS['out-of-scope'])) + }) + + // ── Filter chips ────────────────────────────────────────────────────────── + + it('filter chips group has role="group" and aria-label', () => { + const wrapper = mountView() + const group = wrapper.find('[role="group"][aria-label]') + expect(group.exists()).toBe(true) + }) + + it('renders 6 filter chips (all + 5 statuses)', () => { + const wrapper = mountView() + const group = wrapper.find('[role="group"]') + expect(group.exists()).toBe(true) + const chips = group.findAll('button') + expect(chips.length).toBe(6) + }) + + it('filter chips have aria-pressed attributes', () => { + const wrapper = mountView() + const group = wrapper.find('[role="group"]') + const chips = group.findAll('button') + chips.forEach((chip) => { + expect(chip.attributes('aria-pressed')).toMatch(/^true|false$/) + }) + }) + + it('"all" chip is aria-pressed=true initially', () => { + const wrapper = mountView() + const group = wrapper.find('[role="group"]') + const allChip = group.findAll('button').find((b) => b.text().includes('coverage.filter.all')) + expect(allChip).toBeDefined() + expect(allChip!.attributes('aria-pressed')).toBe('true') + }) + + it('clicking a status filter chip filters rows to that status only', async () => { + const wrapper = mountView() + const group = wrapper.find('[role="group"]') + // Click "matched" filter + const matchedChip = group + .findAll('button') + .find((b) => b.text().includes('coverage.filter.matched')) + expect(matchedChip).toBeDefined() + await matchedChip!.trigger('click') + + expect(matchedChip!.attributes('aria-pressed')).toBe('true') + const dataRows = wrapper.findAll('tr[data-row]') + const expectedMatchedCount = COVERAGE_MATRIX.flatMap((s) => s.rows).filter( + (r) => r.status === 'matched', + ).length + expect(dataRows.length).toBe(expectedMatchedCount) + }) + + it('clicking active filter again returns to "all"', async () => { + const wrapper = mountView() + const group = wrapper.find('[role="group"]') + const matchedChip = group + .findAll('button') + .find((b) => b.text().includes('coverage.filter.matched'))! + await matchedChip.trigger('click') + // Now click "all" chip + const allChip = group.findAll('button').find((b) => b.text().includes('coverage.filter.all'))! + await allChip.trigger('click') + const dataRows = wrapper.findAll('tr[data-row]') + expect(dataRows.length).toBe(TOTAL_ROWS) + }) + + it('active chip has aria-pressed=true, others false', async () => { + const wrapper = mountView() + const group = wrapper.find('[role="group"]') + const matchedChip = group + .findAll('button') + .find((b) => b.text().includes('coverage.filter.matched'))! + await matchedChip.trigger('click') + + const chips = group.findAll('button') + chips.forEach((chip) => { + if (chip.text().includes('coverage.filter.matched')) { + expect(chip.attributes('aria-pressed')).toBe('true') + } else { + expect(chip.attributes('aria-pressed')).toBe('false') + } + }) + }) + + // ── Section hiding when filter active ──────────────────────────────────── + + it('sections with no matching rows are hidden when filter active', async () => { + const wrapper = mountView() + const group = wrapper.find('[role="group"]') + // Filter to out-of-scope — there are none in COVERAGE_MATRIX + const oosFitler = COUNTS['out-of-scope'] + if (oosFitler === 0) { + // With zero out-of-scope rows, all sections should be hidden + const oosChip = group + .findAll('button') + .find((b) => b.text().includes('coverage.filter.outOfScope'))! + await oosChip.trigger('click') + const dataRows = wrapper.findAll('tr[data-row]') + expect(dataRows.length).toBe(0) + // No section headings should be visible + const sectionHeadings = wrapper.findAll('[data-section]') + expect(sectionHeadings.length).toBe(0) + } else { + // Just verify that filter reduces sections shown + const oosChip = group + .findAll('button') + .find((b) => b.text().includes('coverage.filter.outOfScope'))! + await oosChip.trigger('click') + const dataRows = wrapper.findAll('tr[data-row]') + expect(dataRows.length).toBe(oosFitler) + } + }) + + // ── Row content ─────────────────────────────────────────────────────────── + + it('null web ref renders coverage.none placeholder', () => { + const wrapper = mountView() + // coverage matrix has rows with web=null; find at least one null-web cell + const hasNullWeb = COVERAGE_MATRIX.flatMap((s) => s.rows).some((r) => r.web === null) + if (hasNullWeb) { + expect(wrapper.text()).toContain('coverage.none') + } + }) + + it('route references are rendered in data rows', () => { + const wrapper = mountView() + const text = wrapper.text() + // Check a couple of known routes from the matrix + expect(text).toContain('/login') + expect(text).toContain('/coverage') + }) + + it('capability keys are translated via t()', () => { + const wrapper = mountView() + const text = wrapper.text() + // With identity t(), key is passed through — should see cap keys + expect(text).toContain('coverage.cap.login') + }) + + // ── Sections count ──────────────────────────────────────────────────────── + + it(`renders exactly ${SECTION_COUNT} section groups when filter=all`, () => { + const wrapper = mountView() + const sections = wrapper.findAll('[data-section]') + expect(sections.length).toBe(SECTION_COUNT) + }) +}) diff --git a/packages/devboard/src/views/CoverageView.vue b/packages/devboard/src/views/CoverageView.vue new file mode 100644 index 0000000..b78aeb8 --- /dev/null +++ b/packages/devboard/src/views/CoverageView.vue @@ -0,0 +1,557 @@ + + + + + diff --git a/packages/devboard/src/views/DepsView.spec.ts b/packages/devboard/src/views/DepsView.spec.ts new file mode 100644 index 0000000..6a4c9ef --- /dev/null +++ b/packages/devboard/src/views/DepsView.spec.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { defineComponent } from 'vue' +import DepsView from './DepsView.vue' +import type { CellEntry } from '../manifest/types' +import { useCellsStore } from '../stores/useCellsStore' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (k: string, params?: Record) => { + if (params !== undefined) return `${k}(${JSON.stringify(params)})` + return k + }, + }), +})) + +vi.mock('../stores/useCellsStore', () => ({ + useCellsStore: vi.fn(), +})) + +const RouterLinkStub = defineComponent({ + name: 'RouterLink', + props: { to: { type: String, required: true } }, + template: '', +}) + +const makeCell = (overrides: Partial): CellEntry => ({ + id: 'testcore', + name: 'TestCore', + domain: 'Test', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'TestCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], + ...overrides, +}) + +const STUB_CELLS: CellEntry[] = [ + makeCell({ id: 'configcore', dependsOnCells: [], requiredByCells: ['accesscore', 'auditcore'] }), + makeCell({ id: 'accesscore', dependsOnCells: ['configcore'], requiredByCells: ['auditcore'] }), + makeCell({ + id: 'auditcore', + dependsOnCells: ['accesscore', 'configcore'], + requiredByCells: [], + }), +] + +function setupStoreMock(cells: CellEntry[] = STUB_CELLS): void { + vi.mocked(useCellsStore).mockReturnValue({ + cells: cells as unknown as ReturnType['cells'], + selectedId: null, + selectedCell: null, + byId: (id: string) => cells.find((c) => c.id === id) ?? null, + selectCell: vi.fn(), + $id: 'devboard.cells', + $patch: vi.fn(), + $reset: vi.fn(), + $subscribe: vi.fn(), + $onAction: vi.fn(), + $dispose: vi.fn(), + } as unknown as ReturnType) +} + +function mountView(cells: CellEntry[] = STUB_CELLS) { + setupStoreMock(cells) + return mount(DepsView, { + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + stubActions: false, + }), + ], + stubs: { + RouterLink: RouterLinkStub, + }, + }, + }) +} + +describe('DepsView', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the page title from i18n', () => { + const wrapper = mountView() + expect(wrapper.find('h1').text()).toContain('deps.title') + }) + + it('renders the cell count in the header', () => { + const wrapper = mountView() + expect(wrapper.text()).toContain('deps.count') + }) + + it('renders DepsViewBar with 4 tabs', () => { + const wrapper = mountView() + const tabs = wrapper.findAll('[role="tab"]') + expect(tabs).toHaveLength(4) + }) + + it('list view panel is visible by default', () => { + const wrapper = mountView() + const listPanel = wrapper.find('#deps-panel-list') + expect(listPanel.exists()).toBe(true) + // The list panel should not be hidden + expect(listPanel.attributes('hidden')).toBeUndefined() + }) + + it('other panels are hidden by default', () => { + const wrapper = mountView() + for (const id of ['graph', 'tree', 'matrix']) { + const panel = wrapper.find(`#deps-panel-${id}`) + expect(panel.exists()).toBe(true) + expect(panel.attributes('hidden')).toBeDefined() + } + }) + + it('clicking graph tab shows graph panel and hides list panel', async () => { + const wrapper = mountView() + const graphTab = wrapper.find('#deps-tab-graph') + await graphTab.trigger('click') + + const graphPanel = wrapper.find('#deps-panel-graph') + expect(graphPanel.attributes('hidden')).toBeUndefined() + + const listPanel = wrapper.find('#deps-panel-list') + expect(listPanel.attributes('hidden')).toBeDefined() + }) + + it('clicking tree tab shows tree panel', async () => { + const wrapper = mountView() + const treeTab = wrapper.find('#deps-tab-tree') + await treeTab.trigger('click') + + const treePanel = wrapper.find('#deps-panel-tree') + expect(treePanel.attributes('hidden')).toBeUndefined() + }) + + it('clicking matrix tab shows matrix panel', async () => { + const wrapper = mountView() + const matrixTab = wrapper.find('#deps-tab-matrix') + await matrixTab.trigger('click') + + const matrixPanel = wrapper.find('#deps-panel-matrix') + expect(matrixPanel.attributes('hidden')).toBeUndefined() + }) + + it('detail aside shows empty state message when no cell selected', () => { + const wrapper = mountView() + const aside = wrapper.find('aside') + expect(aside.exists()).toBe(true) + expect(aside.text()).toContain('deps.detail.empty') + }) + + it('selecting a cell in list view shows detail aside with cell info', async () => { + const wrapper = mountView() + // Click on first cell button in the list view + const cellBtn = wrapper.find('.deps-list__cell-btn') + await cellBtn.trigger('click') + + const aside = wrapper.find('aside') + // After selection, the detail aside should no longer show the empty state + expect(aside.text()).not.toContain('deps.detail.empty') + // Should show dependsOn/requiredBy headings + expect(aside.text()).toContain('deps.detail.dependsOn') + expect(aside.text()).toContain('deps.detail.requiredBy') + }) + + it('all tabpanels have role=tabpanel', () => { + const wrapper = mountView() + const panels = wrapper.findAll('[role="tabpanel"]') + expect(panels).toHaveLength(4) + }) + + it('tabpanel aria-labelledby corresponds to tab id', () => { + const wrapper = mountView() + const listPanel = wrapper.find('#deps-panel-list') + expect(listPanel.attributes('aria-labelledby')).toBe('deps-tab-list') + }) + + it('close button in detail aside calls select(null)', async () => { + const wrapper = mountView() + // First select a cell + const cellBtn = wrapper.find('.deps-list__cell-btn') + await cellBtn.trigger('click') + + // Now close button should be visible + const closeBtn = wrapper.find('.deps-view__detail-close') + expect(closeBtn.exists()).toBe(true) + await closeBtn.trigger('click') + + // After closing, empty state should reappear + const aside = wrapper.find('aside') + expect(aside.text()).toContain('deps.detail.empty') + }) +}) diff --git a/packages/devboard/src/views/DepsView.vue b/packages/devboard/src/views/DepsView.vue new file mode 100644 index 0000000..4312b5e --- /dev/null +++ b/packages/devboard/src/views/DepsView.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/packages/devboard/src/views/GroupsView.spec.ts b/packages/devboard/src/views/GroupsView.spec.ts new file mode 100644 index 0000000..412caad --- /dev/null +++ b/packages/devboard/src/views/GroupsView.spec.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import GroupsView from './GroupsView.vue' +import { useCellsStore } from '../stores/useCellsStore' +import type { CellEntry } from '../manifest/types' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (k: string, params?: Record) => { + if (params !== undefined) return `${k}(${JSON.stringify(params)})` + return k + }, + }), +})) + +// Mock useCellsStore so we control `cells` (real store reads static manifest). +vi.mock('../stores/useCellsStore', () => ({ + useCellsStore: vi.fn(), +})) + +const makeCell = (overrides: Partial): CellEntry => ({ + id: 'testcore', + name: 'TestCore', + domain: 'Test', + type: 'core', + consistencyLevel: 'L2', + lifecycle: 'asset', + durabilityMode: 'durable', + owner: { team: 'platform', role: 'cell-owner' }, + goStructName: 'TestCore', + schemaPrimary: null, + requires: [], + l0Dependencies: [], + smokeTests: [], + slices: [], + produces: [], + consumes: [], + dependsOnCells: [], + requiredByCells: [], + ...overrides, +}) + +// Real cells from manifest — keep in sync with cells.generated.ts membership +const STUB_CELLS: CellEntry[] = [ + makeCell({ + id: 'accesscore', + name: 'AccessCore', + domain: 'Access', + consistencyLevel: 'L3', + durabilityMode: 'durable', + dependsOnCells: ['configcore'], + consumes: [ + { contract: 'event.config.entry-deleted.v1', role: 'subscribe' }, + { contract: 'event.config.entry-upserted.v1', role: 'subscribe' }, + { contract: 'event.role.assigned.v1', role: 'subscribe' }, + { contract: 'event.role.revoked.v1', role: 'subscribe' }, + { contract: 'http.config.get.v1', role: 'call' }, + { contract: 'http.config.internal.get.v1', role: 'call' }, + ], + }), + makeCell({ + id: 'auditcore', + name: 'AuditCore', + domain: 'Audit', + consistencyLevel: 'L2', + durabilityMode: 'durable', + dependsOnCells: ['accesscore', 'configcore'], + }), + makeCell({ + id: 'configcore', + name: 'ConfigCore', + domain: 'Config', + consistencyLevel: 'L3', + durabilityMode: 'durable', + dependsOnCells: [], + }), +] + +function setupStoreMock(cells: CellEntry[] = STUB_CELLS): void { + vi.mocked(useCellsStore).mockReturnValue({ + cells: cells as unknown as ReturnType['cells'], + selectedId: null, + selectedCell: null, + byId: (id: string) => cells.find((c) => c.id === id) ?? null, + selectCell: vi.fn(), + $id: 'devboard.cells', + $patch: vi.fn(), + $reset: vi.fn(), + $subscribe: vi.fn(), + $onAction: vi.fn(), + $dispose: vi.fn(), + } as unknown as ReturnType) +} + +function mountView(cells: CellEntry[] = STUB_CELLS) { + setupStoreMock(cells) + return mount(GroupsView, { + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + stubActions: false, + }), + ], + }, + }) +} + +describe('GroupsView', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ── Header ─────────────────────────────────────────────────────────────── + + it('renders all 5 group rows in the list', () => { + const wrapper = mountView() + const groupBtns = wrapper.findAll('[data-testid="group-btn"]') + expect(groupBtns.length).toBe(5) + }) + + it('preview badge is present with role="status"', () => { + const wrapper = mountView() + const badge = wrapper.find('[role="status"]') + expect(badge.exists()).toBe(true) + expect(badge.text()).toContain('groups.preview.label') + }) + + it('preview note is present with role="note"', () => { + const wrapper = mountView() + const note = wrapper.find('[role="note"]') + expect(note.exists()).toBe(true) + expect(note.text()).toContain('groups.preview.note') + }) + + // ── Search ─────────────────────────────────────────────────────────────── + + it('search input has an associated label (for/id pair)', () => { + const wrapper = mountView() + const input = wrapper.find('input[type="search"]') + expect(input.exists()).toBe(true) + const inputId = input.attributes('id') + expect(inputId).toBeTruthy() + const label = wrapper.find(`label[for="${inputId}"]`) + expect(label.exists()).toBe(true) + }) + + it('typing filters the group list', async () => { + const wrapper = mountView() + const input = wrapper.find('input[type="search"]') + await input.setValue('access') + const groupBtns = wrapper.findAll('[data-testid="group-btn"]') + // Only the access group should match by nameKey + expect(groupBtns.length).toBeLessThan(5) + }) + + it('shows empty state when no groups match the search query', async () => { + const wrapper = mountView() + const input = wrapper.find('input[type="search"]') + await input.setValue('zzz-no-match-at-all') + expect(wrapper.text()).toContain('groups.empty') + }) + + // ── Group selection ────────────────────────────────────────────────────── + + it('clicking a group shows its detail panel', async () => { + const wrapper = mountView() + const groupBtns = wrapper.findAll('[data-testid="group-btn"]') + await groupBtns[0]!.trigger('click') + // Detail panel should show query section + expect(wrapper.text()).toContain('groups.detail.query') + }) + + it('selected group button has aria-pressed="true"', async () => { + const wrapper = mountView() + const groupBtns = wrapper.findAll('[data-testid="group-btn"]') + await groupBtns[0]!.trigger('click') + expect(groupBtns[0]!.attributes('aria-pressed')).toBe('true') + }) + + it('unselected group buttons have aria-pressed="false"', async () => { + const wrapper = mountView() + const groupBtns = wrapper.findAll('[data-testid="group-btn"]') + // Select first button + await groupBtns[0]!.trigger('click') + // Second button should be unselected + expect(groupBtns[1]!.attributes('aria-pressed')).toBe('false') + }) + + // ── Detail panel ───────────────────────────────────────────────────────── + + it('detail panel shows predicate chips for the selected group', async () => { + const wrapper = mountView() + const groupBtns = wrapper.findAll('[data-testid="group-btn"]') + await groupBtns[0]!.trigger('click') + // Predicate chips should be present + const chips = wrapper.findAll('[data-testid="predicate-chip"]') + expect(chips.length).toBeGreaterThan(0) + }) + + it('member table has th elements with scope="col"', async () => { + const wrapper = mountView() + const groupBtns = wrapper.findAll('[data-testid="group-btn"]') + await groupBtns[0]!.trigger('click') + const headers = wrapper.findAll('table th[scope="col"]') + expect(headers.length).toBeGreaterThan(0) + }) + + it('sg-foundation selected: members contain configcore (no dependencies)', async () => { + const wrapper = mountView() + const groupBtns = wrapper.findAll('[data-testid="group-btn"]') + // sg-foundation is index 2 in SMART_GROUPS + await groupBtns[2]!.trigger('click') + expect(wrapper.text()).toContain('configcore') + }) + + it('sg-access selected: members contain accesscore', async () => { + const wrapper = mountView() + const groupBtns = wrapper.findAll('[data-testid="group-btn"]') + // sg-access is index 0 in SMART_GROUPS + await groupBtns[0]!.trigger('click') + expect(wrapper.text()).toContain('accesscore') + }) + + it('shows no-members message when a group has no matching cells', async () => { + // sg-high-fanin requires consumesCount >= 5 — only accesscore qualifies (6 consumes), + // but here we pass cells with fewer consumes so the group is empty + const emptyCells = STUB_CELLS.map((c) => makeCell({ ...c, consumes: [] })) + const wrapper = mountView(emptyCells) + const groupBtns = wrapper.findAll('[data-testid="group-btn"]') + // sg-high-fanin is index 3 + await groupBtns[3]!.trigger('click') + expect(wrapper.text()).toContain('groups.detail.noMembers') + }) + + it('shows placeholder when no group is selected (detail empty state)', () => { + // Default: first group is auto-selected so click a scenario where nothing is selected + // We test the empty state by checking default render before any interaction + // — with default auto-select the first group is shown. We verify detail.empty + // is shown when explicitly deselecting (if supported), or just verify initial state. + // Since the component auto-selects the first group, we just verify the member count + // badge appears in the detail panel on initial render. + const wrapper = mountView() + expect(wrapper.text()).toContain('groups.detail.members') + }) + + // ── Accessibility ──────────────────────────────────────────────────────── + + it('group list container has an accessible label', () => { + const wrapper = mountView() + // Should have aria-label or role on the list container + const nav = wrapper.find('[aria-label]') + expect(nav.exists()).toBe(true) + }) + + it('cell ids in member table use mono font class', async () => { + const wrapper = mountView() + const groupBtns = wrapper.findAll('[data-testid="group-btn"]') + await groupBtns[0]!.trigger('click') + // cell-id span should have mono class + const monoSpan = wrapper.find('.groups__member-id') + expect(monoSpan.exists()).toBe(true) + }) +}) diff --git a/packages/devboard/src/views/GroupsView.vue b/packages/devboard/src/views/GroupsView.vue new file mode 100644 index 0000000..2a08535 --- /dev/null +++ b/packages/devboard/src/views/GroupsView.vue @@ -0,0 +1,532 @@ + + + + + From 38ab86c7e0ee5539e40c18e92ee0f9b50ef8369b Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Wed, 3 Jun 2026 05:57:41 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(devboard):=20PR=20#37=20=E5=85=AD?= =?UTF-8?q?=E7=BB=B4=E5=BA=A6=20review=20=E2=80=94=20i18n=20=E7=BC=BA?= =?UTF-8?q?=E9=94=AE=20+=20a11y=20+=20=E7=B1=BB=E5=9E=8B/=E6=80=A7?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 实跑 6 reviewer(diff≥1500),修复全部 Cx1/Cx2 IN_SCOPE finding: i18n(最关键,运行时 bug): - 补全 10 个被引用但未定义的 key(contracts.detail.{panelLabel,kind}、 contracts.kind.filterLabel、coverage.{empty,kpi.regionLabel,legend.regionLabel, filter.groupLabel}、deps.detail.{label,close}、groups.detail.and)——vue-i18n 非 key-typed + spec mock t 导致 typecheck/test/build 全过、仅 review 抓到。 另补 deps.list.tableLabel 供 a11y 修复用。zh-CN + en-US 同步,MessageSchema 校验。 a11y: - CoverageView: aria-label 漏冒号绑定(渲染字面量)→ :aria-label;section heading scope=colgroup → role=rowheader。 - ContractsView: (table 角色非法)→ aria-current。 - GovernanceGatesPanel: 删除徽章重复 aria-label(保留 sr-only),避免双重朗读。 - DepsTreeView: 递归子树叶节点补 sr-only leaf 文本;外层 div+aria-label 无 role → section + aria-labelledby。 - DepsViewBar: aria-controls 覆盖所有 tab(panel 常驻 DOM,APG)。 - DepsListView / GroupsView: 表格补 aria-label / aria-labelledby;groups 详情区补标签。 类型/性能: - ContractsView: selectedEnvelope computed 消除 responseEnvelopeFor 重复调用 + ! 断言;合并同源 import。 - useRovingTablist 泛型化 ,DepsViewBar 去 as 强转(CellTabBar 向后兼容)。 - DepsGraphView: SVG font-size=11 魔法数字 → CSS class var(--text-xs);边 :key 用稳定 from-to。 - GroupsView: 去冗余 ?? [];memberCount 改 computed Map 备忘。 - DepsTreeView 递归子组件 emits 类型化。 延后(OUT_OF_SCOPE 既有债,非本 PR 文件):CellDetailView v1-mono 未定义类、 CellsListView ?? [] 冗余。 devboard 366 测试绿、覆盖率 98%;apps/web 74 绿、build OK;-w lint/typecheck 0 issue。 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/i18n/messages/en-US.ts | 11 ++++++++ packages/core/src/i18n/messages/zh-CN.ts | 11 ++++++++ .../contracts/GovernanceGatesPanel.vue | 6 +---- .../src/components/deps/DepsGraphView.vue | 10 +++++--- .../src/components/deps/DepsListView.vue | 2 +- .../src/components/deps/DepsTreeView.vue | 13 +++++++--- .../src/components/deps/DepsViewBar.spec.ts | 8 +++--- .../src/components/deps/DepsViewBar.vue | 6 ++--- .../src/components/deps/DepsViews.spec.ts | 25 +++++++++++++++++++ .../src/composables/useRovingTablist.ts | 18 +++++++------ .../devboard/src/views/ContractsView.spec.ts | 4 +-- packages/devboard/src/views/ContractsView.vue | 14 +++++++---- .../devboard/src/views/CoverageView.spec.ts | 13 ++++++++++ packages/devboard/src/views/CoverageView.vue | 6 ++--- packages/devboard/src/views/DepsView.vue | 8 +++--- .../devboard/src/views/GroupsView.spec.ts | 17 +++++++++++++ packages/devboard/src/views/GroupsView.vue | 19 +++++++------- 17 files changed, 139 insertions(+), 52 deletions(-) diff --git a/packages/core/src/i18n/messages/en-US.ts b/packages/core/src/i18n/messages/en-US.ts index 3cc3788..649b889 100644 --- a/packages/core/src/i18n/messages/en-US.ts +++ b/packages/core/src/i18n/messages/en-US.ts @@ -843,6 +843,7 @@ const enUS = { all: 'All', http: 'HTTP', event: 'Event', + filterLabel: 'Filter by kind', }, table: { contract: 'Contract', @@ -874,6 +875,7 @@ const enUS = { }, detail: { empty: 'Select a contract to see details.', + panelLabel: 'Contract details', servers: 'Served by', callers: 'Called by', none: 'None', @@ -881,6 +883,7 @@ const enUS = { envelopeNone: 'No response envelope snapshot for this contract.', status: 'Status', object: 'Response struct', + kind: 'Response kind', }, envelopeKind: { success: 'Success response', @@ -904,6 +907,7 @@ const enUS = { dependsOn: 'Depends on', requiredBy: 'Required by', none: 'None', + tableLabel: 'Cell dependency list', }, matrix: { caption: 'Cell dependency matrix: rows depend on columns.', @@ -921,6 +925,8 @@ const enUS = { }, detail: { empty: 'Select a cell to see its dependencies.', + label: 'Cell dependency detail', + close: 'Dismiss', dependsOn: 'Depends on', requiredBy: 'Required by', none: 'None', @@ -932,12 +938,14 @@ const enUS = { title: 'gocell-web ↔ devboard coverage', subtitle: 'Design routes vs shipped Vue views. Hand-maintained; truth source dev-coverage.jsx.', kpi: { + regionLabel: 'KPI summary', shipped: 'Routes shipped', drafted: 'Routes drafted', gaps: 'Gaps to close', outOfScope: 'Out of scope', }, legend: { + regionLabel: 'Legend', matched: 'Matched', partial: 'Partial', designOnly: 'Design-only', @@ -945,6 +953,7 @@ const enUS = { outOfScope: 'Out of scope', }, filter: { + groupLabel: 'Filter by status', all: 'All', matched: 'Matched', partial: 'Partial', @@ -966,6 +975,7 @@ const enUS = { }, caption: 'gocell-web implementation progress against design coverage.', none: '—', + empty: 'No capabilities match the current filter.', section: { auth: 'Auth & onboarding', meta: 'Meta', @@ -1012,6 +1022,7 @@ const enUS = { detail: { empty: 'Select a group to see its rule and members.', query: 'Rule', + and: 'AND', members: 'Members', memberCount: '{n} members', noMembers: 'No cell currently satisfies this rule.', diff --git a/packages/core/src/i18n/messages/zh-CN.ts b/packages/core/src/i18n/messages/zh-CN.ts index ac17ef3..3c005fb 100644 --- a/packages/core/src/i18n/messages/zh-CN.ts +++ b/packages/core/src/i18n/messages/zh-CN.ts @@ -830,6 +830,7 @@ const zhCN = { all: '全部', http: 'HTTP', event: '事件', + filterLabel: '按类型筛选', }, table: { contract: '契约', @@ -861,6 +862,7 @@ const zhCN = { }, detail: { empty: '选择一个契约查看详情。', + panelLabel: '契约详情', servers: '提供方', callers: '调用方', none: '无', @@ -868,6 +870,7 @@ const zhCN = { envelopeNone: '该契约暂无响应信封快照。', status: '状态码', object: '响应结构', + kind: '响应类型', }, envelopeKind: { success: '成功响应', @@ -891,6 +894,7 @@ const zhCN = { dependsOn: '依赖', requiredBy: '被依赖', none: '无', + tableLabel: 'cell 依赖列表', }, matrix: { caption: 'cell 依赖矩阵:行依赖列。', @@ -908,6 +912,8 @@ const zhCN = { }, detail: { empty: '选择一个 cell 查看其依赖。', + label: 'Cell 依赖详情', + close: '关闭', dependsOn: '依赖', requiredBy: '被依赖', none: '无', @@ -919,12 +925,14 @@ const zhCN = { title: 'gocell-web ↔ devboard 覆盖矩阵', subtitle: '设计稿路由与已实现 Vue 视图的对照进度。手工维护,真相源 dev-coverage.jsx。', kpi: { + regionLabel: 'KPI 汇总', shipped: '已交付路由', drafted: '设计稿路由', gaps: '待补差距', outOfScope: '范围外', }, legend: { + regionLabel: '图例', matched: '已对齐', partial: '部分', designOnly: '仅设计', @@ -932,6 +940,7 @@ const zhCN = { outOfScope: '范围外', }, filter: { + groupLabel: '按状态筛选', all: '全部', matched: '已对齐', partial: '部分', @@ -953,6 +962,7 @@ const zhCN = { }, caption: 'gocell-web 实现进度与设计稿覆盖对照矩阵。', none: '—', + empty: '当前筛选无匹配的能力。', section: { auth: '认证与引导', meta: 'Meta', @@ -999,6 +1009,7 @@ const zhCN = { detail: { empty: '选择一个分组查看规则与成员。', query: '规则', + and: 'AND', members: '成员', memberCount: '{n} 个成员', noMembers: '当前无 cell 满足该规则。', diff --git a/packages/devboard/src/components/contracts/GovernanceGatesPanel.vue b/packages/devboard/src/components/contracts/GovernanceGatesPanel.vue index 7d0eb35..b94f2df 100644 --- a/packages/devboard/src/components/contracts/GovernanceGatesPanel.vue +++ b/packages/devboard/src/components/contracts/GovernanceGatesPanel.vue @@ -32,11 +32,7 @@ function verdictKey(verdict: GateVerdict): string { {{ t(gate.descKey) }} - + {{ t(verdictKey(gate.verdict)) }} diff --git a/packages/devboard/src/components/deps/DepsGraphView.vue b/packages/devboard/src/components/deps/DepsGraphView.vue index c0c9714..4b0460c 100644 --- a/packages/devboard/src/components/deps/DepsGraphView.vue +++ b/packages/devboard/src/components/deps/DepsGraphView.vue @@ -126,8 +126,8 @@ const edgePaths = computed(() => { (() => { dominant-baseline="middle" fill="var(--fg)" font-family="var(--font-mono)" - font-size="11" + class="deps-graph__label" > {{ node.id }} @@ -201,6 +201,10 @@ const edgePaths = computed(() => { stroke-width: 2; } +.deps-graph__label { + font-size: var(--text-xs); +} + .deps-graph__empty { color: var(--fg-faint); font-size: var(--text-sm); diff --git a/packages/devboard/src/components/deps/DepsListView.vue b/packages/devboard/src/components/deps/DepsListView.vue index 23edb07..461e7a1 100644 --- a/packages/devboard/src/components/deps/DepsListView.vue +++ b/packages/devboard/src/components/deps/DepsListView.vue @@ -15,7 +15,7 @@ const { t } = useI18n()