Files
OpenTerminal/tests/ot-var-names.mjs
T
Bill 3b86d62111 fix(theme): --ot-* 变量名 camelCase/kebab-case 不匹配导致浅色分支静默失效
CSS 自定义属性名大小写敏感,而 semanticVars() 的键是 camelCase、样式表读的是
kebab-case:chrome.ts 直接写 `--ot-${key}` 时,9 个多词变量(--ot-text-muted、
--ot-text-secondary、--ot-surface-raised、--ot-border-strong、--ot-success-fg、
--ot-danger-fg、--ot-warning-fg、--ot-info-fg、--ot-scrollbar-active、
--ot-focus-ring)从未真正生效,元素一律回落到 global.css :root 的深色兜底值——
浅色主题正是靠这些变量修可读性的,等于白改(--ot-text-muted 48 处引用仍是
#8a8a8a,白底 3.25:1;--ot-danger-fg 仍是 #f85149,3.15:1)。

- 键 `muted` 改名 `textMuted`(CSS 读的是 --ot-text-muted)
- 新增 semantic.ts 的 cssVarName()/semanticCssVars(),chrome.ts 只调用它们,
  命名逻辑单一来源
- 新增 tests/ot-var-names.mjs:把「CSS 引用名 = JS 写入名 = :root 声明名」
  三集合锁在一起(值测试看不出这类问题),已用「去掉转换」验证会失败
- AGENTS/STATE 同步该铁律;测试数 22/25 的陈旧计数修正为实际的 29
2026-10-16 23:29:03 +08:00

87 lines
4.3 KiB
JavaScript

/**
* Fail-closed guard for the --ot-* semantic layer's NAMES (not values).
*
* CSS custom properties are case-sensitive, so `setProperty('--ot-dangerFg')`
* does NOT satisfy `var(--ot-danger-fg)`: the reference just falls back to the
* dark :root literal and the light theme silently loses its fix. Value tests
* (semantic-theme / theme-contrast) cannot see that, so this test locks the
* three sets together:
* 1) every var(--ot-*) referenced in renderer source is actually emitted;
* 2) the :root static fallback block declares exactly the emitted names;
* 3) both semanticVars() branches emit the same key set (a key that only
* exists on one branch leaves the other theme with a stale :root value).
* Also self-tests the kebab converter so a no-op can't make (1) vacuous.
* Run: node tests/ot-var-names.mjs (must exit 0)
*/
import fs from 'node:fs'
import path from 'node:path'
import { cssVarName, semanticCssVars } from '../src/renderer/src/theme/semantic.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
const ROOT = path.resolve('src/renderer/src')
// The naming lives in semantic.ts (cssVarName) and is the SAME function
// chrome.ts calls via semanticCssVars — so a regression there fails here.
const names = (vars) => new Set(Object.keys(vars).filter((k) => k !== 'color-scheme'))
// --- self-test: the converter must actually convert ------------------------
console.log('cssVarName:')
ok(cssVarName('textMuted') === '--ot-text-muted', 'textMuted -> --ot-text-muted')
ok(cssVarName('dangerFg') === '--ot-danger-fg', 'dangerFg -> --ot-danger-fg')
ok(cssVarName('text') === '--ot-text', 'text -> --ot-text (unchanged)')
ok(cssVarName('colorScheme') === 'color-scheme', 'colorScheme -> color-scheme')
// --- what JS emits, per branch ---------------------------------------------
const darkVars = semanticCssVars({ background: '#1e1e1e', foreground: '#cccccc' }, false)
const lightVars = semanticCssVars({ background: '#f6f8fa', foreground: '#383a42' }, true)
const darkNames = names(darkVars)
const lightNames = names(lightVars)
console.log('branches emit the same key set:')
const onlyDark = [...darkNames].filter((n) => !lightNames.has(n)).sort()
const onlyLight = [...lightNames].filter((n) => !darkNames.has(n)).sort()
ok(onlyDark.length === 0, `no dark-only keys${onlyDark.length ? ` (${onlyDark.join(', ')})` : ''}`)
ok(onlyLight.length === 0, `no light-only keys${onlyLight.length ? ` (${onlyLight.join(', ')})` : ''}`)
ok(darkVars['color-scheme'] === 'dark' && lightVars['color-scheme'] === 'light', 'color-scheme switches per branch')
// --- what the CSS references ------------------------------------------------
function walk(dir, out = []) {
for (const f of fs.readdirSync(dir)) {
const p = path.join(dir, f)
if (fs.statSync(p).isDirectory()) walk(p, out)
else if (/\.(css|tsx|ts)$/.test(f)) out.push(p)
}
return out
}
const referenced = new Set()
for (const file of walk(ROOT)) {
for (const m of fs.readFileSync(file, 'utf8').matchAll(/var\((--ot-[A-Za-z0-9-]+)/g)) referenced.add(m[1])
}
console.log('every referenced var is emitted:')
const dangling = [...referenced].filter((n) => !darkNames.has(n)).sort()
ok(dangling.length === 0, dangling.length ? `dangling: ${dangling.join(', ')}` : `${referenced.size} reference(s) all emitted`)
// --- the static :root block in global.css ----------------------------------
const css = fs.readFileSync(path.join(ROOT, 'global.css'), 'utf8')
const rootBlock = /:root\s*\{([\s\S]*?)\}/.exec(css)
const declared = new Set()
if (rootBlock) for (const m of rootBlock[1].matchAll(/(--ot-[A-Za-z0-9-]+)\s*:/g)) declared.add(m[1])
console.log(':root fallback block matches the emitted set (no dark-only leftovers):')
const undeclared = [...darkNames].filter((n) => !declared.has(n)).sort()
const extra = [...declared].filter((n) => !darkNames.has(n)).sort()
ok(rootBlock !== null, ':root block found in global.css')
ok(undeclared.length === 0, undeclared.length ? `emitted but not declared: ${undeclared.join(', ')}` : 'all emitted names declared')
ok(extra.length === 0, extra.length ? `declared but never emitted: ${extra.join(', ')}` : 'no extra declared names')
if (failed > 0) {
console.error(`ot-var-names: ${failed} check(s) failed`)
process.exit(1)
}
console.log('ot-var-names: all ok')