Files
goodbuddy/src/renderer/src/WorkspacePrimitives.test.tsx
T
mesalogo 2833a27f83 feat: add bounded diagnostics and improve UX feedback
Ask and Execute guidance, Runtime state, live announcements, loading states,
retry paths, settings focus, and update failures now provide consistent and
accessible feedback across the desktop experience.

Add bounded redacted Desktop and Agent diagnostic logs, a read-only Agent
diagnostics command, and an explicit opt-in for attaching a small Desktop
diagnostic summary to in-app feedback. Diagnostics stay local by default and
the existing feedback v1 service contract remains compatible.

Release note: 新增可选桌面诊断反馈与 Agent 诊断导出,并统一 Ask/Execute 状态、错误恢复和无障碍反馈;诊断默认保留在本地,仅在用户明确勾选时附加桌面摘要。
2026-08-31 15:35:13 +08:00

758 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// <reference types="node" />
import {
cleanup,
fireEvent,
render,
screen
} from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
DestructiveConfirmActions,
EmptyState,
PageHeader,
PageShell,
PageTabs,
SegmentedControl,
ScopeBadge
} from './WorkspacePrimitives'
const stylesheet = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'),
'utf8'
)
const rendererEntry = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'main.tsx'),
'utf8'
)
const fontSetup = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'fonts.ts'),
'utf8'
)
function themeTokens(selector: string): Record<string, string> {
const selectorIndex = stylesheet.indexOf(selector)
const blockStart = stylesheet.indexOf('{', selectorIndex)
const blockEnd = stylesheet.indexOf('}', blockStart)
if (selectorIndex < 0 || blockStart < 0 || blockEnd < 0) {
throw new Error(`Missing token block ${selector}`)
}
const block = stylesheet.slice(blockStart + 1, blockEnd)
return Object.fromEntries(
[...block.matchAll(/--([\w-]+):\s*(#[\da-f]{3,6});/giu)].map(
([, name, value]) => [name, value]
)
)
}
function contrast(foreground: string, background: string): number {
const luminance = (hex: string): number => {
const compact = hex.slice(1)
const expanded =
compact.length === 3
? compact
.split('')
.map((value) => `${value}${value}`)
.join('')
: compact
const [red, green, blue] = expanded
.match(/../gu)!
.map((value) => Number.parseInt(value, 16) / 255)
.map((value) =>
value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4
)
return red! * 0.2126 + green! * 0.7152 + blue! * 0.0722
}
const first = luminance(foreground)
const second = luminance(background)
return (
(Math.max(first, second) + 0.05) /
(Math.min(first, second) + 0.05)
)
}
describe('WorkspacePrimitives', () => {
afterEach(() => {
cleanup()
})
it('keeps shared switch rows aligned without standalone dividers', () => {
const toggleRowStyles = stylesheet.match(
/\.toggle-row\s*\{(?<rules>[^}]*)\}/u
)?.groups?.rules
expect(toggleRowStyles).toBeDefined()
expect(toggleRowStyles).toMatch(/align-items:\s*center;/u)
expect(toggleRowStyles).not.toMatch(/border-top:/u)
expect(toggleRowStyles).not.toMatch(/padding-top:/u)
})
it('keeps Platform Features tabs visible in scrollable Settings', () => {
const platformFeaturesTabs = stylesheet.match(
/\.platform-features-tabs\s*\{(?<rules>[^}]*)\}/u
)?.groups?.rules
expect(platformFeaturesTabs).toBeDefined()
expect(platformFeaturesTabs).toMatch(/flex:\s*0 0 auto;/u)
})
it('keeps whole-surface dialog scrollbars outside close-button hitboxes', () => {
expect(stylesheet).toMatch(
/:is\(\s*\.project-create-card,\s*\.knowledge-citation-dialog__surface,\s*\.channel-qr-dialog,\s*\.document-parsing-diagnostic,\s*\.mcp-editor\s*\)\s*\{[^}]*scrollbar-gutter:\s*stable;/u
)
})
it('uses bundled variable fonts and readable shared type tokens', () => {
expect(rendererEntry).toContain(
"@fontsource-variable/noto-sans-sc/wght.css"
)
expect(rendererEntry).toContain('installBundledUiFonts()')
expect(fontSetup).toContain(
'inter-latin-standard-normal.woff2?url'
)
expect(fontSetup).toContain(
'inter-latin-standard-italic.woff2?url'
)
expect(stylesheet).toMatch(/--font-body:\s*13px/u)
expect(stylesheet).toMatch(/--font-caption:\s*11px/u)
expect(stylesheet).toMatch(
/:root\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/--text-disabled:\s*#[\da-f]{6};/iu
)
expect(stylesheet).toMatch(/--z-dialog:\s*130;/u)
expect(stylesheet).toMatch(/font-synthesis:\s*style/u)
expect(stylesheet).toContain(
'"Inter Variable", "Segoe UI Variable", "SF Pro Text", "PingFang SC"'
)
expect(stylesheet).toContain(
'"Microsoft YaHei UI", "Noto Sans SC Variable"'
)
expect(stylesheet).toMatch(
/\.nav-item span:nth-child\(2\)\s*\{[^}]*font-size:\s*var\(--font-body\);[^}]*font-weight:\s*500;/u
)
expect(stylesheet).toMatch(
/\.section-label\s*\{[^}]*font-size:\s*var\(--font-caption\);[^}]*font-weight:\s*600;/u
)
expect(stylesheet).toMatch(
/\.conversation-item__primary\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.conversation-item small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.runtime-overview__details dt\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.runtime-overview__details dd\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.markdown-content\s*\{[^}]*font-size:\s*14px;[^}]*line-height:\s*1\.65;/u
)
expect(stylesheet).toMatch(
/\.composer__input textarea\s*\{[^}]*font-size:\s*14px;[^}]*line-height:\s*1\.6;/u
)
})
it('exposes an unavailable scope explanation to assistive technology', () => {
render(
<ScopeBadge
scope={{
kind: 'unavailable',
explanation: '项目已归档,请选择其他项目。'
}}
/>
)
expect(
screen.getByText('范围不可用')
).toHaveAccessibleDescription('项目已归档,请选择其他项目。')
})
it('labels multi-project scope distinctly from mixed global scope', () => {
const { rerender } = render(
<ScopeBadge
scope={{
kind: 'projects',
projectCount: 2
}}
/>
)
expect(screen.getByLabelText('2 个项目')).toHaveTextContent(
'2 个项目'
)
expect(screen.queryByText(/全局/u)).not.toBeInTheDocument()
rerender(<ScopeBadge scope={{ kind: 'mixed' }} />)
expect(screen.getByLabelText('项目 + 全局')).toBeInTheDocument()
})
it('keeps forced-color and docked split contracts authoritative', () => {
const forcedColorsStart = stylesheet.indexOf(
'@media (forced-colors: active)'
)
const forcedColorsEnd = stylesheet.indexOf(
'@media (prefers-reduced-motion: reduce)',
forcedColorsStart
)
const forcedColors = stylesheet.slice(
forcedColorsStart,
forcedColorsEnd
)
expect(forcedColors).toContain(':root :is(')
expect(forcedColors).not.toContain(':where(')
expect(forcedColors).toMatch(
/html:root\s+:is\(\s*\.assistant-sidebar__tab--active,\s*\.page-tabs__tab--active\s*\),\s*html:root\[data-theme='dark'\]\s+:is\(\s*\.assistant-sidebar__tab--active,\s*\.page-tabs__tab--active\s*\)\s*\{[^}]*border:\s*2px solid Highlight;[^}]*background:\s*Highlight;[^}]*color:\s*HighlightText;[^}]*forced-color-adjust:\s*none;/u
)
expect(forcedColors).toMatch(
/\.task-status-dot--failed,[\s\S]*?forced-color-adjust:\s*none;/u
)
expect(forcedColors).toMatch(
/:root \.conversation-task-child__completed-status\s*\{[^}]*color:\s*Canvas;/u
)
expect(forcedColors).toMatch(
/\.heartbeat-center__meter\s*\{[^}]*forced-color-adjust:\s*none;/u
)
expect(stylesheet).toMatch(
/\.assistant-sidebar--open\s*\{[^}]*width:\s*var\(--assistant-sidebar-width,\s*30%\);[^}]*flex-basis:\s*var\(--assistant-sidebar-width,\s*30%\);/u
)
expect(stylesheet).not.toMatch(
/@media \(max-width: 719px\)\s*\{(?:[^{}]|\{[^{}]*\})*?\.assistant-sidebar/u
)
expect(stylesheet).toMatch(
/\.composer-wrap\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*100%;[^}]*min-width:\s*0;[^}]*padding:\s*var\(--space-3\)\s*max\(var\(--page-gutter\),\s*calc\(\(100% - var\(--content-reading\)\) \/ 2\)\)\s*var\(--space-2\);/u
)
expect(stylesheet).toMatch(
/\.assistant-sidebar__tabs\s*\{[^}]*grid-template-columns:\s*repeat\(5,\s*minmax\(max-content,\s*1fr\)\);/u
)
expect(stylesheet).toMatch(
/\.assistant-sidebar__tab\s*\{[^}]*white-space:\s*nowrap;/u
)
expect(stylesheet).toMatch(
/\.app-frame\s*\{[^}]*display:\s*grid;[^}]*flex:\s*1;[^}]*grid-template-rows:\s*58px minmax\(0,\s*1fr\);/u
)
expect(stylesheet).toMatch(
/\.app-shell\s*\{[^}]*display:\s*flex;[^}]*width:\s*100%;[^}]*height:\s*100%;/u
)
expect(stylesheet).toMatch(
/\.release-notes-backdrop\s*\{[^}]*position:\s*fixed;[^}]*background:\s*var\(--overlay-backdrop\);[^}]*inset:\s*0;/u
)
expect(stylesheet).toMatch(
/\.app-content\s*\{[^}]*position:\s*relative;[^}]*display:\s*flex;[^}]*width:\s*100%;[^}]*height:\s*100%;/u
)
expect(stylesheet).toMatch(
/\.workspace\s*\{[^}]*grid-template-rows:\s*minmax\(0,\s*1fr\);/u
)
expect(stylesheet).toMatch(
/\.assistant-sidebar-toggle\s*\{[^}]*position:\s*absolute;[^}]*top:\s*6px;[^}]*right:\s*6px;/u
)
expect(stylesheet).toMatch(
/\.conversation-task-strip\s*\{[^}]*min-height:\s*46px;[^}]*padding:\s*0\s+max\(/u
)
expect(stylesheet).toMatch(
/\.conversation-task-strip__header\s*\{[^}]*min-height:\s*45px;/u
)
expect(stylesheet).toMatch(
/@media \(max-width: 720px\)\s*\{[\s\S]*?\.conversation-task-strip\s*\{[^}]*padding-right:\s*46px;[^}]*padding-left:\s*var\(--space-3\);/u
)
expect(stylesheet).not.toMatch(
/@media \(max-width: 720px\)\s*\{[\s\S]*?\.conversation-task-strip__header\s*\{[^}]*flex-direction:\s*column;/u
)
expect(stylesheet).toMatch(
/\.task-center__filters \.segmented-control\s*\{[^}]*width:\s*100%;[^}]*overflow:\s*hidden;/u
)
expect(stylesheet).toMatch(
/\.task-center__filters \.segmented-control__option\s*\{[^}]*min-width:\s*0;[^}]*flex:\s*1;/u
)
})
it('floats Runtime context compaction without shifting the composer', () => {
expect(stylesheet).toMatch(
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*background:\s*var\(--surface-raised\);/u
)
expect(stylesheet).toMatch(
/\.composer-meta\s*\{[^}]*position:\s*relative;[^}]*min-height:\s*26px;[^}]*margin:\s*var\(--space-1\) 0 0;/u
)
expect(stylesheet).toMatch(
/\.composer-meta--with-context-compact\s*\{[^}]*padding-left:\s*calc\(/u
)
expect(stylesheet).toMatch(
/\.composer-context-compact\s*\{[^}]*position:\s*absolute;[^}]*bottom:\s*0;[^}]*left:\s*0;/u
)
})
it('reveals the conversation action without reserving its space', () => {
expect(stylesheet).toMatch(
/\.conversation-row:has\(\.conversation-task-toggle\) \.conversation-item\s*\{[^}]*padding-left:\s*34px;/u
)
expect(stylesheet).toMatch(
/\.conversation-task-toggle\s*\{[^}]*left:\s*3px;[^}]*width:\s*28px;[^}]*height:\s*28px;/u
)
expect(stylesheet).not.toMatch(
/\.conversation-row \.conversation-item\s*\{[^}]*padding-right:/u
)
expect(stylesheet).toMatch(
/\.conversation-more\s*\{[^}]*right:\s*5px;/u
)
expect(stylesheet).toMatch(
/\.conversation-row:hover \.conversation-more,\s*\.conversation-row:focus-within \.conversation-more,[^{]*\{[^}]*opacity:\s*1;/u
)
expect(stylesheet).toMatch(
/\.conversation-row:hover \.conversation-item small,[^{]*\.conversation-item small\s*\{[^}]*opacity:\s*0;/u
)
expect(stylesheet).not.toContain(
'.conversation-row--active .conversation-more'
)
expect(stylesheet).toMatch(
/\.conversation-row:has\(\.conversation-activity-indicator\)\s+\.conversation-item\s*\{[^}]*padding-right:\s*28px;/u
)
expect(stylesheet).toMatch(
/\.conversation-activity-indicator\s*\{[^}]*right:\s*10px;/u
)
expect(stylesheet).not.toContain('.conversation-delete')
})
it('shows conversation provenance with a horizontal fork badge', () => {
expect(stylesheet).toMatch(
/\.conversation-branch-badge\s*\{[^}]*width:\s*20px;[^}]*height:\s*18px;[^}]*background:\s*var\(--accent-subtle\);[^}]*color:\s*var\(--accent\);/u
)
expect(stylesheet).toMatch(
/\.conversation-branch-badge svg,\s*\.conversation-branch-icon\s*\{[^}]*transform:\s*rotate\(90deg\);/u
)
})
it('uses the shared control and menu surfaces for the rich project picker', () => {
expect(stylesheet).toMatch(
/\.project-switcher__trigger\s*\{[^}]*height:\s*var\(--control-height\);[^}]*border-radius:\s*var\(--radius-control\);[^}]*background:\s*var\(--surface-raised\);/u
)
expect(stylesheet).toMatch(
/\.project-switcher__menu\s*\{[^}]*border-radius:\s*var\(--radius-card\);[^}]*background:\s*var\(--surface-raised\);[^}]*box-shadow:\s*var\(--shadow-dialog\);/u
)
})
it('keeps project forms and disabled actions visually consistent', () => {
expect(stylesheet).toMatch(
/\.project-create-card label > span\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.project-create-card input,\s*\.project-create-card textarea,\s*\.project-create-card select\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.project-create-card label > small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.project-create-card__error\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.field > span\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.field input,\s*\.field textarea,\s*\.field select\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.field small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.primary-button:disabled\s*\{[^}]*cursor:\s*not-allowed;/u
)
expect(stylesheet).toMatch(
/\.secondary-button:disabled\s*\{[^}]*cursor:\s*not-allowed;/u
)
expect(stylesheet).toMatch(
/\.danger-button:disabled\s*\{[^}]*cursor:\s*not-allowed;/u
)
expect(stylesheet).toContain(
'.primary-button:hover:not(:disabled)'
)
expect(stylesheet).toContain(
'.danger-button:hover:not(:disabled)'
)
expect(stylesheet).not.toMatch(
/\.primary-button:disabled\s*\{[^}]*cursor:\s*wait;/u
)
})
it('separates Runtime-specific controls from the main composer toolbar', () => {
expect(stylesheet).toMatch(
/\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u
)
expect(stylesheet).toMatch(
/\.composer__runtime-toolbar\s*\{[^}]*display:\s*flex;[^}]*min-height:\s*52px;[^}]*border-top:\s*1px solid var\(--border-default\);[^}]*background:\s*var\(--surface-muted\);/u
)
expect(stylesheet).toMatch(
/\.composer__runtime-controls\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;/u
)
expect(stylesheet).toMatch(
/\.composer-picker--runtime > \.model-button,\s*\.composer-picker--runtime-action > \.model-button\s*\{[^}]*width:\s*220px;/u
)
expect(stylesheet).toMatch(
/\.composer__runtime-controls \.composer-picker\s*\{[^}]*flex-basis:\s*220px;/u
)
})
it('keeps shared controls keyboard and pointer accessible at narrow widths', () => {
expect(stylesheet).toMatch(
/button\s*>\s*svg,\s*button\s*>\s*svg\s+\*\s*\{[^}]*pointer-events:\s*none;/u
)
expect(stylesheet).toMatch(
/button\s*>\s*svg\s*\{[^}]*display:\s*block;[^}]*flex:\s*0 0 auto;/u
)
expect(stylesheet).toMatch(
/\.primary-button,\s*\.secondary-button,\s*\.danger-button\s*\{[^}]*display:\s*inline-flex;[^}]*align-items:\s*center;[^}]*justify-content:\s*center;[^}]*gap:\s*var\(--space-2\);/u
)
expect(stylesheet).toMatch(
/button:focus-visible,\s*input:focus-visible,\s*select:focus-visible,\s*textarea:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--accent\);/u
)
expect(stylesheet).toMatch(
/\.page-tabs\s*\{[^}]*overflow-x:\s*auto;[^}]*flex-wrap:\s*nowrap;/u
)
expect(stylesheet).toMatch(
/\.knowledge-graph__detail > \.page-tabs--segmented\s*\{[^}]*min-height:\s*36px;[^}]*overflow:\s*hidden;[^}]*flex:\s*0 0 auto;/u
)
expect(stylesheet).toMatch(
/\.knowledge-graph__detail > \.page-tabs--segmented \.page-tabs__tab\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*flex:\s*1;/u
)
expect(stylesheet).toMatch(
/\.knowledge-source-row__actions > button\s*\{[^}]*height:\s*var\(--control-height\);[^}]*min-height:\s*var\(--control-height\);[^}]*padding:\s*0 var\(--space-3\);[^}]*align-self:\s*center;/u
)
expect(stylesheet).not.toContain(
'.heartbeat-center > .page-tabs {\n display: grid;'
)
})
it('keeps shared switches from inheriting text-field dimensions', () => {
expect(stylesheet).toMatch(
/\.toggle-row input\s*\{[^}]*min-width:\s*38px;[^}]*width:\s*38px;[^}]*min-height:\s*22px;[^}]*height:\s*22px;[^}]*margin:\s*0;/u
)
})
it('keeps DSH marketplace search text clear of its icon', () => {
expect(stylesheet).toMatch(
/\.field \.runtime-extension-marketplace__search-input > input\s*\{[^}]*padding-left:\s*34px;/u
)
})
it('separates model service fields from credential status', () => {
expect(stylesheet).toMatch(
/\.model-service-form\s*\{[^}]*display:\s*grid;[^}]*gap:\s*var\(--space-3\);/u
)
})
it('uses the design-system page gutters at each window width', () => {
expect(stylesheet).toMatch(/--page-gutter:\s*32px;/u)
expect(stylesheet).toMatch(
/@media \(max-width: 1199px\)\s*\{\s*:root\s*\{\s*--page-gutter:\s*24px;/u
)
expect(stylesheet).toMatch(
/@media \(max-width: 959px\)\s*\{\s*:root\s*\{\s*--page-gutter:\s*16px;/u
)
expect(stylesheet).toMatch(
/\.page-shell--master-detail\s*\{[^}]*padding:\s*var\(--page-gutter\);/u
)
})
it('keeps full-page settings navigation readable and content fluid', () => {
expect(stylesheet).toMatch(
/\.settings-page \.settings-panel__header\s*\{[^}]*padding:\s*var\(--page-gutter\) var\(--page-gutter\) 0;/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-panel__header \.page-header\s*\{[^}]*padding-bottom:\s*var\(--space-4\);[^}]*border-bottom:\s*1px solid var\(--border-subtle\);/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-panel__body\s*\{[^}]*padding:\s*var\(--space-6\) var\(--page-gutter\) var\(--page-gutter\);[^}]*grid-template-columns:\s*220px minmax\(0,\s*1fr\);/u
)
expect(stylesheet).toMatch(
/@media \(max-width: 1020px\)\s*\{[\s\S]*?\.settings-page \.settings-panel__body\s*\{[^}]*grid-template-columns:\s*196px minmax\(0,\s*1fr\);/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-tabs\s*\{[^}]*scrollbar-gutter:\s*auto;/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-panel__content\s*\{[^}]*width:\s*min\(100%,\s*var\(--content-standard\)\);/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-tabs button strong\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-tabs button small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
})
it('keeps Runtime customization hierarchy and dangerous actions clear', () => {
expect(stylesheet).toMatch(
/\.runtime-customization-section__header strong\s*\{[^}]*font-size:\s*var\(--font-section-title\);/u
)
expect(stylesheet).toMatch(
/\.runtime-customization-editor\s*\{[^}]*padding:\s*0;[^}]*border:\s*0;[^}]*background:\s*transparent;/u
)
expect(stylesheet).toMatch(
/\.runtime-native-inventory__status\s*\{[^}]*display:\s*flex;[^}]*min-height:\s*42px;/u
)
expect(stylesheet).not.toContain(
'.runtime-native-inventory__header'
)
expect(stylesheet).toMatch(
/\.runtime-customization-section__dirty\s*\{[^}]*display:\s*flex;[^}]*justify-content:\s*space-between;/u
)
expect(stylesheet).toMatch(
/\.danger-ghost\s*\{[^}]*color:\s*var\(--danger\);[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.danger-ghost:disabled\s*\{[^}]*color:\s*var\(--text-muted\);[^}]*cursor:\s*not-allowed;/u
)
})
it('keeps knowledge settings cards separated as page sections', () => {
expect(stylesheet).toMatch(
/\.knowledge-settings\s*\{[^}]*display:\s*grid;[^}]*width:\s*min\(920px,\s*100%\);[^}]*gap:\s*var\(--space-6\);/u
)
expect(stylesheet).toMatch(
/\.knowledge-settings--index\s*\{[^}]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\);/u
)
expect(stylesheet).toMatch(
/\.knowledge-settings--graph\s*\{[^}]*grid-template-columns:\s*minmax\(280px,\s*0\.7fr\)\s*minmax\(0,\s*1\.3fr\);/u
)
expect(stylesheet).toMatch(
/\.knowledge-settings\s*>\s*section\s*\{[^}]*display:\s*grid;[^}]*gap:\s*var\(--space-4\);/u
)
expect(stylesheet).toMatch(
/\.knowledge-settings__toggle\s*\{[^}]*display:\s*grid;[^}]*align-items:\s*flex-start;[^}]*justify-content:\s*initial;[^}]*grid-template-columns:\s*auto minmax\(0,\s*1fr\);/u
)
})
it('renders a consistent page shell and scoped header', () => {
render(
<PageShell variant="dashboard">
<PageHeader
description="跨项目记录"
eyebrow="AUDIT"
headingId="page-title"
scope={{ kind: 'all-projects' }}
title="任务与活动"
/>
</PageShell>
)
expect(
screen.getByRole('heading', { level: 1, name: '任务与活动' })
).toBeInTheDocument()
expect(screen.getByText('全部项目')).toHaveClass('scope-badge')
expect(screen.getByText('全部项目').closest('.page-shell')).toHaveClass(
'page-shell--dashboard'
)
})
it('supports arrow-key tab and segmented-control selection', () => {
const onTabChange = vi.fn()
const onSegmentChange = vi.fn()
render(
<>
<PageTabs
ariaLabel="视图"
idPrefix="example"
onChange={onTabChange}
tabs={[
{ id: 'first', label: '第一个' },
{ id: 'second', label: '第二个' }
]}
value="first"
/>
<SegmentedControl
ariaLabel="筛选"
onChange={onSegmentChange}
options={[
{ value: 'all', label: '全部' },
{ value: 'failed', label: '失败' }
]}
value="all"
/>
</>
)
fireEvent.keyDown(screen.getByRole('tab', { name: '第一个' }), {
key: 'ArrowRight'
})
expect(onTabChange).toHaveBeenCalledWith('second')
fireEvent.keyDown(screen.getByRole('button', { name: '全部' }), {
key: 'End'
})
expect(onSegmentChange).toHaveBeenCalledWith('failed')
})
it('distinguishes page and section empty states', () => {
const { rerender } = render(
<EmptyState
description="创建内容后会显示在这里。"
level="page"
title="尚无内容"
/>
)
expect(screen.getByText('尚无内容').parentElement).toHaveClass(
'empty-state--page'
)
rerender(
<EmptyState
description="没有符合当前筛选的内容。"
level="section"
title="没有匹配结果"
/>
)
expect(screen.getByText('没有匹配结果').parentElement).toHaveClass(
'empty-state--section'
)
})
it('announces shared loading empty states consistently', () => {
render(
<EmptyState
description="正在读取内容。"
title="正在加载"
variant="loading"
/>
)
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-live', 'polite')
expect(status).toHaveAttribute('aria-busy', 'true')
})
it('uses the shared destructive confirmation flow', () => {
const onRequestConfirm = vi.fn()
const onConfirm = vi.fn()
const onCancel = vi.fn()
const { rerender } = render(
<DestructiveConfirmActions
confirmAriaLabel="永久删除对象"
confirmLabel="确认删除"
confirming={false}
icon={<span data-testid="delete-icon">×</span>}
onCancel={onCancel}
onConfirm={onConfirm}
onRequestConfirm={onRequestConfirm}
triggerLabel="删除"
/>
)
expect(screen.getByTestId('delete-icon').parentElement).toHaveAttribute(
'aria-hidden',
'true'
)
fireEvent.click(screen.getByRole('button', { name: '删除' }))
expect(onRequestConfirm).toHaveBeenCalledOnce()
rerender(
<DestructiveConfirmActions
confirmAriaLabel="永久删除对象"
confirmLabel="确认删除"
confirming
message="删除此对象?"
onCancel={onCancel}
onConfirm={onConfirm}
onRequestConfirm={onRequestConfirm}
triggerLabel="删除"
/>
)
const dialog = screen.getByRole('alertdialog', {
name: '永久删除对象'
})
const cancelButton = screen.getByRole('button', { name: '取消' })
const confirmButton = screen.getByRole('button', {
name: '永久删除对象'
})
expect(dialog).not.toHaveAttribute('aria-modal')
expect(dialog).toHaveAccessibleDescription('删除此对象?')
expect(cancelButton).toHaveFocus()
expect(
fireEvent.keyDown(cancelButton, { key: 'Tab', shiftKey: true })
).toBe(true)
expect(cancelButton).toHaveFocus()
fireEvent.keyDown(cancelButton, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledOnce()
fireEvent.click(confirmButton)
expect(onConfirm).toHaveBeenCalledOnce()
rerender(
<DestructiveConfirmActions
confirmAriaLabel="永久删除对象"
confirmLabel="确认删除"
confirming={false}
onCancel={onCancel}
onConfirm={onConfirm}
onRequestConfirm={onRequestConfirm}
triggerLabel="删除"
/>
)
expect(screen.getByRole('button', { name: '删除' })).toHaveFocus()
})
it('keeps focus on the inline confirmation while actions are disabled', () => {
const onCancel = vi.fn()
const { rerender } = render(
<DestructiveConfirmActions
confirmLabel="确认删除"
confirming={false}
onCancel={onCancel}
onConfirm={vi.fn()}
onRequestConfirm={vi.fn()}
triggerLabel="删除"
/>
)
rerender(
<DestructiveConfirmActions
confirmLabel="正在删除"
confirming
disabled
onCancel={onCancel}
onConfirm={vi.fn()}
onRequestConfirm={vi.fn()}
triggerLabel="删除"
/>
)
const dialog = screen.getByRole('alertdialog', {
name: '正在删除'
})
expect(dialog).toHaveFocus()
expect(fireEvent.keyDown(dialog, { key: 'Tab' })).toBe(true)
expect(dialog).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(onCancel).not.toHaveBeenCalled()
})
it.each([
[':root', 'light'],
[":root[data-theme='dark']", 'dark']
])('keeps shared %s semantic tokens contrast-safe', (selector) => {
const tokens = themeTokens(selector)
const textPairs = [
['text-muted', 'surface-raised'],
['accent', 'accent-subtle'],
['success', 'success-subtle'],
['danger', 'danger-subtle'],
['text-on-accent', 'accent-solid'],
['text-on-accent', 'danger-solid']
] as const
for (const [foreground, background] of textPairs) {
expect(
contrast(tokens[foreground]!, tokens[background]!),
`${foreground} on ${background}`
).toBeGreaterThanOrEqual(4.5)
}
expect(
contrast(tokens['border-control']!, tokens['surface-raised']!)
).toBeGreaterThanOrEqual(3)
})
})