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(' 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) }) })