160 lines
7.3 KiB
TypeScript
160 lines
7.3 KiB
TypeScript
import { readdirSync, readFileSync, statSync } from 'fs'
|
||
import { join } from 'path'
|
||
import { describe, expect, it } from 'vitest'
|
||
|
||
const readClientFile = (path: string) => readFileSync(`packages/client/src/${path}`, 'utf8')
|
||
|
||
const walk = (dir: string, out: string[] = []): string[] => {
|
||
for (const name of readdirSync(dir)) {
|
||
const full = join(dir, name)
|
||
if (statSync(full).isDirectory()) walk(full, out)
|
||
else if (/\.vue$/.test(name)) out.push(full)
|
||
}
|
||
return out
|
||
}
|
||
|
||
describe('client style system', () => {
|
||
it('keeps shared page headers on one layout baseline', () => {
|
||
const globalStyles = readClientFile('styles/global.scss')
|
||
|
||
expect(globalStyles).toContain('min-height: 64px;')
|
||
expect(globalStyles).toContain('padding: 14px 20px;')
|
||
expect(globalStyles).toContain('.page-header > .header-actions')
|
||
})
|
||
|
||
it('aligns SCSS surfaces and radii with the shared theme tokens', () => {
|
||
const variables = readClientFile('styles/variables.scss')
|
||
|
||
// CYBER 是单色暗色主题:卡片面必须是暗色,且亮度分层(page < sidebar < card < input)。
|
||
expect(variables).toContain('--bg-card: oklch(0.185 0.014 248);')
|
||
expect(variables).toContain('--bg-input: oklch(0.105 0.012 252);')
|
||
expect(variables).toContain('$radius-sm: 6px;')
|
||
expect(variables).toContain('$radius-md: 8px;')
|
||
expect(variables).toContain('$radius-lg: 8px;')
|
||
// 亮色主题与 comic 风格已移除:不能再有 .dark / .comic 变体块。
|
||
expect(variables).not.toMatch(/^\.dark\s*{/m)
|
||
expect(variables).not.toMatch(/^\.comic\s*{/m)
|
||
})
|
||
|
||
it('bridges CYBER tokens into Arco CSS variables', () => {
|
||
// 没有 ConfigProvider,Arco 的实际外观由 global.scss 里的 --color-* 覆盖决定。
|
||
const globalStyles = readClientFile('styles/global.scss')
|
||
|
||
expect(globalStyles).toContain('--color-primary: oklch(0.805 0.148 188);')
|
||
expect(globalStyles).toContain('--color-bg-1: oklch(0.155 0.012 250);')
|
||
expect(globalStyles).toContain('--color-border-1: oklch(0.305 0.018 242);')
|
||
})
|
||
|
||
it('exports the frozen Arco theme tokens', async () => {
|
||
const { arcoTheme } = await import('../../packages/client/src/styles/arco-theme')
|
||
|
||
expect(arcoTheme.colorPrimary).toBeTruthy()
|
||
expect(arcoTheme.colorBgLayout).toBeTruthy()
|
||
expect(arcoTheme.colorBgContainer).toBeTruthy()
|
||
expect(arcoTheme.borderRadius).toBeTypeOf('number')
|
||
expect(arcoTheme.fontFamily).toContain('PingFang SC')
|
||
expect(arcoTheme.fontFamilyMono).toContain('JetBrains Mono')
|
||
})
|
||
|
||
it('pins Arco to dark mode on the root element', () => {
|
||
// 不 import useTheme:它在模块顶层读 localStorage,node 环境下会抛。
|
||
// 断言源码契约即可,真实行为由 useTheme 自己的测试覆盖。
|
||
const source = readClientFile('composables/useTheme.ts')
|
||
|
||
expect(source).toContain("setAttribute('arco-theme'")
|
||
// 单主题:不再有 light / comic 变体,只固定 dark。
|
||
expect(source).not.toMatch(/classList\.toggle\(['"](dark|comic)/)
|
||
expect(source).not.toMatch(/localStorage\.getItem\(['"]hermes_(brightness|style)/)
|
||
})
|
||
|
||
it('exposes the CYBER page shell so pages stop falling back to bare Arco layouts', () => {
|
||
// 原型里每个页面都是 panel / panel-h / panel-b / kv / tabs 这套外壳。
|
||
// 25 个页面各写一遍同样的框会漂移,所以抽成全局类并在这里锁住。
|
||
const globalStyles = readClientFile('styles/global.scss')
|
||
|
||
for (const cls of ['.cy-view', '.cy-panel', '.cy-panel-h', '.cy-panel-b', '.cy-kv', '.cy-tabs', '.cy-tab']) {
|
||
expect(globalStyles).toContain(cls)
|
||
}
|
||
|
||
// 选中态必须是霓虹青 + 发丝下划线,不是胶囊底色。
|
||
const onRule = globalStyles.slice(globalStyles.indexOf('&.on {'), globalStyles.indexOf('&.dis {'))
|
||
expect(onRule).toContain('color: $accent-primary;')
|
||
expect(onRule).toContain('border-bottom-color: $accent-primary;')
|
||
})
|
||
|
||
it('keeps the settings shell on the shared page header baseline', () => {
|
||
// 设置页是唯一有 11 个 pane 的页面,最容易退回 Arco 的 a-tabs 默认外观。
|
||
const settingsView = readClientFile('views/hermes/SettingsView.vue')
|
||
|
||
expect(settingsView).toContain('cy-tabs')
|
||
expect(settingsView).toContain('cy-panel')
|
||
// 不再依赖 a-tabs / a-tab-pane。
|
||
expect(settingsView).not.toContain('<a-tabs')
|
||
expect(settingsView).not.toContain('<a-tab-pane')
|
||
// pane 顺序是契约:account 必须在 users 之前,其余按固定顺序。
|
||
const order = ['account', 'users', 'display', 'proxy', 'agent', 'memory',
|
||
'compression', 'session', 'privacy', 'models', 'voice']
|
||
const positions = order.map(key => settingsView.indexOf(`key: "${key}"`))
|
||
expect(positions.every(p => p > 0)).toBe(true)
|
||
expect([...positions].sort((a, b) => a - b)).toEqual(positions)
|
||
})
|
||
|
||
it('keeps mobile inputs at 16px so iOS does not zoom on focus', () => {
|
||
const globalStyles = readClientFile('styles/global.scss')
|
||
|
||
// Arco 的输入框类名,不是 naive 的 .n-input__*
|
||
expect(globalStyles).toContain('.arco-input')
|
||
expect(globalStyles).not.toMatch(/\.n-input/)
|
||
})
|
||
|
||
it('defines the whole CYBER skeleton class inventory', () => {
|
||
// 原型里的每个外壳类都必须在 global.scss 有对应实现,否则页面会退回裸布局。
|
||
const globalStyles = readClientFile('styles/global.scss')
|
||
const required = [
|
||
'cy-view', 'cy-panel', 'cy-panel-h', 'cy-panel-b', 'cy-kpis', 'cy-kpi',
|
||
'cy-kv', 'cy-grid2', 'cy-grid4', 'cy-field', 'cy-tag', 'cy-code',
|
||
'cy-tabs', 'cy-tab', 'cy-stack', 'cy-sec-t', 'cy-slist-t',
|
||
'cy-tbl-wrap', 'cy-tbl', 'cy-led', 'cy-meter',
|
||
]
|
||
|
||
const missing = required.filter(cls => !globalStyles.includes(`.${cls} `))
|
||
expect(missing, `global.scss 缺少骨架类:${missing.join(', ')}`).toEqual([])
|
||
})
|
||
|
||
it('keeps the CYBER skeleton coverage above the agreed floor', () => {
|
||
// 覆盖率 = 用到 cy-* 骨架类的视图数 / 全部视图数。
|
||
// 不用骨架类的页面是有意为之(聊天流、看板、画布等专用布局),
|
||
// 所以卡一个下限而不是要求 100%,防止新页面裸奔。
|
||
const viewsDir = 'packages/client/src/views/hermes'
|
||
const views = walk(viewsDir)
|
||
.map(p => p.replace(/\\/g, '/'))
|
||
.filter(p => p.endsWith('.vue'))
|
||
|
||
const withSkeleton = views.filter(p =>
|
||
/class="[^"]*\bcy-(view|panel|kpis|kpi|kv|tabs|tab|grid2|grid4|sec-t|slist-t|tbl|tbl-wrap|meter|led|tag|code|field|stack)\b/.test(
|
||
readFileSync(p, 'utf8'),
|
||
),
|
||
)
|
||
|
||
// 这些页面是专用布局,不用通用骨架:聊天流 / 看板 / 画布 / 终端 / 缺失态。
|
||
// 它们的视觉由各自的组件与令牌负责,不该被塞进 panel 结构。
|
||
const intentional = [
|
||
'ChatView.vue', 'GlobalAgentView.vue', 'GroupChatView.vue',
|
||
'HermesMissingView.vue', 'KanbanView.vue', 'TerminalView.vue', 'WorkflowView.vue',
|
||
]
|
||
|
||
const uncovered = views
|
||
.filter(p => !withSkeleton.includes(p))
|
||
.map(p => p.split('/').pop()!)
|
||
.filter(name => !intentional.includes(name))
|
||
|
||
expect(uncovered, `这些页面没有 CYBER 骨架类,也不是有意豁免:${uncovered.join(', ')}`).toEqual([])
|
||
|
||
const ratio = withSkeleton.length / views.length
|
||
expect(
|
||
ratio,
|
||
`骨架类覆盖率 ${(ratio * 100).toFixed(0)}% 低于下限 60%(${withSkeleton.length}/${views.length})。`,
|
||
).toBeGreaterThanOrEqual(0.6)
|
||
})
|
||
})
|