mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
- 波函数改 (sin+1)/2:消除 max(0,sin) 平直暗带(约 6 行宽) - 主色相连续旋转(0.03°/ms,12s/圈全色环):蓝→紫→品红→红→橙→黄→绿→青 - 文字 overlay 同步色相旋转(rotateHue 应用到 Faster/▲/档位名/分隔线/副标签) - 淡入淡出动画:fadeColor/fadeCells + fade 状态机 ~300ms 进出过渡 - 副标签固定 ultracode 段下方,不跟随光标移动 - 顶部/底部各加一行纯波纹行,视觉一致 - 宽度自适应终端列数:窄则 72,宽则铺满(computeSegment/computeRippleSourceX) - 快捷键改 plain Text,不参与波纹背景渲染 - 新增 18 测试(fadeColor/fadeCells/rotateHue/getHueShiftAtTime) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
502 lines
16 KiB
TypeScript
502 lines
16 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
||
import {
|
||
type Cell,
|
||
type Overlay,
|
||
TRANSPARENT,
|
||
applyOverlaysToCells,
|
||
cellsToSegments,
|
||
computeRippleCells,
|
||
fadeCells,
|
||
fadeColor,
|
||
getHueShiftAtTime,
|
||
intensityToColor,
|
||
rotateHue,
|
||
} from '../rippleAnimation.js'
|
||
|
||
describe('intensityToColor', () => {
|
||
test('intensity=0 → 最暗档(不再是 transparent,作面板底色)', () => {
|
||
expect(intensityToColor(0)).toBe('#1a1f3a')
|
||
})
|
||
|
||
test('intensity < 0 钳到 0 → 最暗档', () => {
|
||
expect(intensityToColor(-0.5)).toBe('#1a1f3a')
|
||
})
|
||
|
||
test('intensity > 0 → 永远是 #hex 颜色字符串(不返回 transparent)', () => {
|
||
for (const v of [0.05, 0.1, 0.2, 0.5, 0.8]) {
|
||
const c = intensityToColor(v)
|
||
expect(c).not.toBe(TRANSPARENT)
|
||
expect(c).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||
}
|
||
})
|
||
|
||
test('intensity > 1 钳到 1 → 最高强度颜色', () => {
|
||
expect(intensityToColor(1.5)).toBe(intensityToColor(1))
|
||
})
|
||
|
||
test('intensity 单调递增 → 颜色档位递增(至少 3 档)', () => {
|
||
const samples = [0.2, 0.4, 0.6, 0.8, 1.0]
|
||
const colors = samples.map(intensityToColor)
|
||
const unique = new Set(colors)
|
||
expect(unique.size).toBeGreaterThanOrEqual(3)
|
||
})
|
||
|
||
test('intensity=1 → suggestion 档(波峰最高档)', () => {
|
||
expect(intensityToColor(1)).toBe('#5769F7')
|
||
})
|
||
|
||
test('hueShift=0 → 与无 hueShift 相同(快路径)', () => {
|
||
for (const v of [0, 0.2, 0.5, 0.8, 1]) {
|
||
expect(intensityToColor(v, 0)).toBe(intensityToColor(v))
|
||
}
|
||
})
|
||
|
||
test('hueShift ≠ 0 → 返回不同颜色(但仍是合法 hex)', () => {
|
||
const base = intensityToColor(0.8)
|
||
const shifted = intensityToColor(0.8, 30)
|
||
expect(shifted).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||
expect(shifted).not.toBe(base)
|
||
})
|
||
|
||
test('hueShift 180° → 大致补色(亮色变暗色族)', () => {
|
||
// #5769F7 ≈ HSL(233, 91, 65),旋转 180° → HSL(53, 91, 65) ≈ 黄色系
|
||
const shifted = intensityToColor(1, 180)
|
||
expect(shifted).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||
// 不再是蓝紫族(R 分量应明显大于 B 分量)
|
||
const r = parseInt(shifted.slice(1, 3), 16)
|
||
const b = parseInt(shifted.slice(5, 7), 16)
|
||
expect(r).toBeGreaterThan(b)
|
||
})
|
||
})
|
||
|
||
describe('rotateHue', () => {
|
||
test('hueShift=0 → 原样返回(快路径,无 round-trip 误差)', () => {
|
||
expect(rotateHue('#5769F7', 0)).toBe('#5769F7')
|
||
expect(rotateHue('#1a1f3a', 0)).toBe('#1a1f3a')
|
||
})
|
||
|
||
test('旋转 360° → 等同原色(一圈回起点,大小写无关)', () => {
|
||
expect(rotateHue('#5769F7', 360).toLowerCase()).toBe('#5769f7')
|
||
expect(rotateHue('#5769F7', -360).toLowerCase()).toBe('#5769f7')
|
||
})
|
||
|
||
test('旋转 ±n*360° → 等同原色(任意整圈)', () => {
|
||
expect(rotateHue('#3a4582', 720).toLowerCase()).toBe('#3a4582')
|
||
expect(rotateHue('#3a4582', -1080).toLowerCase()).toBe('#3a4582')
|
||
})
|
||
|
||
test('灰度色(saturation=0)旋转后不变', () => {
|
||
// #808080 = (128,128,128),saturation=0,旋转无意义
|
||
expect(rotateHue('#808080', 90)).toBe('#808080')
|
||
})
|
||
|
||
test('非法 hex → 原样返回(防御式)', () => {
|
||
expect(rotateHue('not-a-color', 90)).toBe('not-a-color')
|
||
expect(rotateHue('#123', 90)).toBe('#123')
|
||
})
|
||
|
||
test('旋转后保持 6 位 hex 格式', () => {
|
||
const rotated = rotateHue('#5769F7', 45)
|
||
expect(rotated).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||
})
|
||
})
|
||
|
||
describe('getHueShiftAtTime', () => {
|
||
test('time=0 → 0', () => {
|
||
expect(getHueShiftAtTime(0)).toBe(0)
|
||
})
|
||
|
||
test('time > 0 → 在 [0, 360) 范围内(连续旋转,非负)', () => {
|
||
for (const t of [100, 500, 1000, 2000, 5000, 10000, 50000, 100000]) {
|
||
const shift = getHueShiftAtTime(t)
|
||
expect(shift).toBeGreaterThanOrEqual(0)
|
||
expect(shift).toBeLessThan(360)
|
||
}
|
||
})
|
||
|
||
test('time 推进 → hueShift 单调递增(模 360)', () => {
|
||
// 在一个周期内(12000ms),hueShift 应单调递增
|
||
const samples = [0, 1000, 2000, 3000, 4000, 5000, 6000]
|
||
const shifts = samples.map(getHueShiftAtTime)
|
||
for (let i = 1; i < shifts.length; i++) {
|
||
expect(shifts[i]).toBeGreaterThan(shifts[i - 1])
|
||
}
|
||
})
|
||
|
||
test('周期 12000ms(time=12000 应回到 0,模 360)', () => {
|
||
// 12000ms * 0.03 = 360,% 360 = 0
|
||
const shift = getHueShiftAtTime(12000)
|
||
expect(shift).toBe(0)
|
||
})
|
||
|
||
test('半周期 6000ms → hueShift=180(对面色相)', () => {
|
||
// 6000ms * 0.03 = 180
|
||
expect(getHueShiftAtTime(6000)).toBe(180)
|
||
})
|
||
|
||
test('四分之一周期 3000ms → hueShift=90', () => {
|
||
expect(getHueShiftAtTime(3000)).toBe(90)
|
||
})
|
||
|
||
test('多周期循环:time=24000 等同 time=0', () => {
|
||
expect(getHueShiftAtTime(24000)).toBe(0)
|
||
expect(getHueShiftAtTime(36000)).toBe(0)
|
||
})
|
||
})
|
||
|
||
describe('computeRippleCells', () => {
|
||
test('返回数组长度等于 width', () => {
|
||
const cells = computeRippleCells({
|
||
y: 2,
|
||
width: 30,
|
||
time: 100,
|
||
sourceX: 25,
|
||
sourceY: 2,
|
||
})
|
||
expect(cells.length).toBe(30)
|
||
})
|
||
|
||
test('每个 cell 的 char 是空格', () => {
|
||
const cells = computeRippleCells({
|
||
y: 0,
|
||
width: 10,
|
||
time: 0,
|
||
sourceX: 5,
|
||
sourceY: 0,
|
||
})
|
||
for (const cell of cells) {
|
||
expect(cell.char).toBe(' ')
|
||
}
|
||
})
|
||
|
||
test('每个 cell 的 color 是合法字符串', () => {
|
||
const cells = computeRippleCells({
|
||
y: 0,
|
||
width: 10,
|
||
time: 0,
|
||
sourceX: 5,
|
||
sourceY: 0,
|
||
})
|
||
for (const cell of cells) {
|
||
expect(typeof cell.color).toBe('string')
|
||
expect(
|
||
cell.color === TRANSPARENT || /^#[0-9a-fA-F]{6}$/.test(cell.color),
|
||
).toBe(true)
|
||
}
|
||
})
|
||
|
||
test('width=0 → 空数组', () => {
|
||
expect(
|
||
computeRippleCells({ y: 0, width: 0, time: 0, sourceX: 0, sourceY: 0 }),
|
||
).toEqual([])
|
||
})
|
||
|
||
test('width<0 → 空数组', () => {
|
||
expect(
|
||
computeRippleCells({ y: 0, width: -5, time: 0, sourceX: 0, sourceY: 0 }),
|
||
).toEqual([])
|
||
})
|
||
|
||
test('震源点 time=0 时为中间档((sin+1)/2 → intensity=0.5),time 推进后扫过波峰/波谷', () => {
|
||
// v5 平滑波:dist=0,time=0 时 phase=0,sin(0)=0,(0+1)/2=0.5 → intensity=0.5 → 中间档
|
||
const t0 = computeRippleCells({
|
||
y: 5,
|
||
width: 11,
|
||
time: 0,
|
||
sourceX: 5,
|
||
sourceY: 5,
|
||
})
|
||
// 0.5 * 7 = 3.5, floor = 3, RIPPLE_COLOR_STOPS[3] = '#2e3870'
|
||
expect(t0[5].color).toBe('#2e3870')
|
||
|
||
// time 推进,phase 变化,震源会扫过波峰(亮档)和波谷(暗档)
|
||
const t1 = computeRippleCells({
|
||
y: 5,
|
||
width: 11,
|
||
time: 1500,
|
||
sourceX: 5,
|
||
sourceY: 5,
|
||
})
|
||
// 不同 time 不同颜色(动画推进)
|
||
expect(t1[5].color).not.toBe('#2e3870')
|
||
})
|
||
|
||
test('覆盖半径扩大:dist=65(左侧远端)仍有非最暗颜色', () => {
|
||
// 震源 x=65,远端 x=0 → dist=65
|
||
// falloff = max(0, 1 - 65/90) = 0.278,波峰时 intensity ≈ 0.278
|
||
// 应映射到非最暗档(#15182b 或更亮)
|
||
const cells = computeRippleCells({
|
||
y: 0,
|
||
width: 66,
|
||
time: 0,
|
||
sourceX: 65,
|
||
sourceY: 0,
|
||
})
|
||
// 第 0 列 dist=65,time=0 时 phase = 65*0.35 = 22.75 rad
|
||
// sin(22.75) ≈ -0.59 → wave = 0 → intensity = 0 → 最暗档
|
||
// 但 time 推进时波峰会扫过此处,强度变高
|
||
// 这里只验证 cell 有合法颜色(最暗档也算合法)
|
||
expect(cells[0].color).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||
// 推进 time 后,左侧应出现非最暗颜色(波峰扫过)
|
||
const t1 = computeRippleCells({
|
||
y: 0,
|
||
width: 66,
|
||
time: 2000,
|
||
sourceX: 65,
|
||
sourceY: 0,
|
||
})
|
||
const nonDarkest = t1.filter(c => c.color !== '#1a1f3a')
|
||
expect(nonDarkest.length).toBeGreaterThan(0)
|
||
})
|
||
|
||
test('time 推进时颜色分布变化(动画效果)', () => {
|
||
const t0 = computeRippleCells({
|
||
y: 2,
|
||
width: 30,
|
||
time: 0,
|
||
sourceX: 25,
|
||
sourceY: 2,
|
||
})
|
||
const t1 = computeRippleCells({
|
||
y: 2,
|
||
width: 30,
|
||
time: 500,
|
||
sourceX: 25,
|
||
sourceY: 2,
|
||
})
|
||
// 至少有一个位置颜色不同
|
||
const diffs = t0.filter((c, i) => c.color !== t1[i].color)
|
||
expect(diffs.length).toBeGreaterThan(0)
|
||
})
|
||
})
|
||
|
||
describe('applyOverlaysToCells', () => {
|
||
function makeCells(colors: string[]): Cell[] {
|
||
return colors.map(c => ({ char: ' ', color: c }))
|
||
}
|
||
|
||
test('无 overlay 时原样返回(但为新数组)', () => {
|
||
const cells = makeCells(['#111', '#222', '#333'])
|
||
const out = applyOverlaysToCells(cells, [])
|
||
expect(out).toEqual(cells)
|
||
expect(out).not.toBe(cells) // 防御式拷贝
|
||
})
|
||
|
||
test('overlay 替换 char 但保留底层 color(color 未指定时)', () => {
|
||
const cells = makeCells([
|
||
TRANSPARENT,
|
||
TRANSPARENT,
|
||
TRANSPARENT,
|
||
TRANSPARENT,
|
||
])
|
||
const overlays: Overlay[] = [{ text: 'hi', x: 1 }]
|
||
const out = applyOverlaysToCells(cells, overlays)
|
||
expect(out[1].char).toBe('h')
|
||
expect(out[2].char).toBe('i')
|
||
expect(out[1].color).toBe(TRANSPARENT) // 保留底层色
|
||
expect(out[0].char).toBe(' ')
|
||
})
|
||
|
||
test('overlay 指定 color 时同时覆盖 char + color', () => {
|
||
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
|
||
const overlays: Overlay[] = [{ text: 'AB', x: 0, color: '#5769F7' }]
|
||
const out = applyOverlaysToCells(cells, overlays)
|
||
expect(out[0]).toEqual({ char: 'A', color: '#5769F7' })
|
||
expect(out[1]).toEqual({ char: 'B', color: '#5769F7' })
|
||
expect(out[2]).toEqual({ char: ' ', color: TRANSPARENT })
|
||
})
|
||
|
||
test('overlay 超出右边界被截断', () => {
|
||
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
|
||
const overlays: Overlay[] = [{ text: 'abcdef', x: 1 }]
|
||
const out = applyOverlaysToCells(cells, overlays)
|
||
expect(out[0].char).toBe(' ')
|
||
expect(out[1].char).toBe('a')
|
||
expect(out[2].char).toBe('b')
|
||
// 'cdef' 被截断
|
||
})
|
||
|
||
test('overlay x 为负数 → 从开头截断(不向左溢出)', () => {
|
||
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
|
||
const overlays: Overlay[] = [{ text: 'abc', x: -1 }]
|
||
const out = applyOverlaysToCells(cells, overlays)
|
||
expect(out[0].char).toBe('b') // 跳过 'a','b' 占 0
|
||
expect(out[1].char).toBe('c')
|
||
expect(out[2].char).toBe(' ')
|
||
})
|
||
|
||
test('多个 overlay 后者覆盖前者(同位置)', () => {
|
||
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
|
||
const overlays: Overlay[] = [
|
||
{ text: 'AAA', x: 0, color: '#111' },
|
||
{ text: 'B', x: 1, color: '#222' },
|
||
]
|
||
const out = applyOverlaysToCells(cells, overlays)
|
||
expect(out[0]).toEqual({ char: 'A', color: '#111' })
|
||
expect(out[1]).toEqual({ char: 'B', color: '#222' }) // 第二个 overlay 覆盖
|
||
expect(out[2]).toEqual({ char: 'A', color: '#111' })
|
||
})
|
||
|
||
test('overlay 起始位置 >= 数组长度 → 完全跳过', () => {
|
||
const cells = makeCells([TRANSPARENT, TRANSPARENT])
|
||
const overlays: Overlay[] = [{ text: 'X', x: 5 }]
|
||
const out = applyOverlaysToCells(cells, overlays)
|
||
expect(out.every(c => c.char === ' ')).toBe(true)
|
||
})
|
||
|
||
test('不修改原数组(防御式拷贝)', () => {
|
||
const cells = makeCells([TRANSPARENT])
|
||
const snapshot = cells.map(c => ({ ...c }))
|
||
applyOverlaysToCells(cells, [{ text: 'X', x: 0 }])
|
||
expect(cells).toEqual(snapshot)
|
||
})
|
||
})
|
||
|
||
describe('cellsToSegments', () => {
|
||
test('空数组 → 空数组', () => {
|
||
expect(cellsToSegments([])).toEqual([])
|
||
})
|
||
|
||
test('单 cell → 单段', () => {
|
||
const cells: Cell[] = [{ char: 'a', color: '#111' }]
|
||
expect(cellsToSegments(cells)).toEqual([{ text: 'a', color: '#111' }])
|
||
})
|
||
|
||
test('全部同色 → 合并为一段', () => {
|
||
const cells: Cell[] = [
|
||
{ char: 'a', color: '#111' },
|
||
{ char: 'b', color: '#111' },
|
||
{ char: 'c', color: '#111' },
|
||
]
|
||
expect(cellsToSegments(cells)).toEqual([{ text: 'abc', color: '#111' }])
|
||
})
|
||
|
||
test('颜色交替 → 每个独立段', () => {
|
||
const cells: Cell[] = [
|
||
{ char: 'a', color: '#111' },
|
||
{ char: 'b', color: '#222' },
|
||
{ char: 'c', color: '#111' },
|
||
]
|
||
expect(cellsToSegments(cells)).toEqual([
|
||
{ text: 'a', color: '#111' },
|
||
{ text: 'b', color: '#222' },
|
||
{ text: 'c', color: '#111' },
|
||
])
|
||
})
|
||
|
||
test('相邻同色段合并,不同色段分开', () => {
|
||
const cells: Cell[] = [
|
||
{ char: 'a', color: TRANSPARENT },
|
||
{ char: 'b', color: TRANSPARENT },
|
||
{ char: 'X', color: '#5769F7' },
|
||
{ char: 'Y', color: '#5769F7' },
|
||
{ char: 'c', color: TRANSPARENT },
|
||
]
|
||
expect(cellsToSegments(cells)).toEqual([
|
||
{ text: 'ab', color: TRANSPARENT },
|
||
{ text: 'XY', color: '#5769F7' },
|
||
{ text: 'c', color: TRANSPARENT },
|
||
])
|
||
})
|
||
|
||
test('段文本拼接顺序保持原顺序', () => {
|
||
const cells: Cell[] = [
|
||
{ char: '1', color: '#111' },
|
||
{ char: '2', color: '#111' },
|
||
{ char: '3', color: '#111' },
|
||
]
|
||
expect(cellsToSegments(cells)[0].text).toBe('123')
|
||
})
|
||
})
|
||
|
||
describe('fadeColor', () => {
|
||
test('fade=1 → 原色(不变)', () => {
|
||
expect(fadeColor('#5769F7', 1)).toBe('#5769f7')
|
||
})
|
||
|
||
test('fade=0 → TRANSPARENT(cell 不渲染)', () => {
|
||
expect(fadeColor('#5769F7', 0)).toBe(TRANSPARENT)
|
||
})
|
||
|
||
test('fade ≤ 0.01 → TRANSPARENT(阈值)', () => {
|
||
expect(fadeColor('#5769F7', 0.01)).toBe(TRANSPARENT)
|
||
expect(fadeColor('#5769F7', 0.009)).toBe(TRANSPARENT)
|
||
})
|
||
|
||
test('fade=0.5 → RGB 各分量减半', () => {
|
||
// #5769F7 = (87, 105, 247),减半 → (44, 53, 124) = #2c357c
|
||
// Math.round(87*0.5)=44, Math.round(105*0.5)=53, Math.round(247*0.5)=124
|
||
expect(fadeColor('#5769F7', 0.5)).toBe('#2c357c')
|
||
})
|
||
|
||
test('TRANSPARENT 输入 → 原样返回(不处理)', () => {
|
||
expect(fadeColor(TRANSPARENT, 1)).toBe(TRANSPARENT)
|
||
expect(fadeColor(TRANSPARENT, 0.5)).toBe(TRANSPARENT)
|
||
})
|
||
|
||
test('非法 hex 格式 → 原样返回(防御式)', () => {
|
||
expect(fadeColor('not-a-color', 0.5)).toBe('not-a-color')
|
||
expect(fadeColor('#123', 0.5)).toBe('#123') // 非 6 位 hex
|
||
})
|
||
|
||
test('fade < 0 钳到 0 → TRANSPARENT', () => {
|
||
expect(fadeColor('#5769F7', -0.5)).toBe(TRANSPARENT)
|
||
})
|
||
|
||
test('fade > 1 钳到 1 → 原色', () => {
|
||
expect(fadeColor('#5769F7', 1.5)).toBe('#5769f7')
|
||
})
|
||
|
||
test('结果始终为 6 位 hex(前导零补全)', () => {
|
||
// #010203 = (1, 2, 3),fade=0.5 → Math.round 后为 (1, 1, 2) = #010102
|
||
// 但 1*0.5 = 0.5, Math.round(0.5) = 1( banker's rounding 在 JS 中是 round half up)
|
||
// 验证格式:6 位 hex
|
||
const result = fadeColor('#010203', 0.5)
|
||
expect(result).toMatch(/^#[0-9a-f]{6}$/)
|
||
})
|
||
})
|
||
|
||
describe('fadeCells', () => {
|
||
test('空数组 → 空数组', () => {
|
||
expect(fadeCells([], 0.5)).toEqual([])
|
||
})
|
||
|
||
test('每个 cell 的颜色按 fade 缩放,char 保留', () => {
|
||
const cells: Cell[] = [
|
||
{ char: ' ', color: '#5769F7' },
|
||
{ char: 'A', color: '#ffffff' },
|
||
]
|
||
const out = fadeCells(cells, 0.5)
|
||
expect(out[0]).toEqual({ char: ' ', color: '#2c357c' })
|
||
// #ffffff = (255, 255, 255),fade=0.5 → (128, 128, 128) = #808080
|
||
expect(out[1]).toEqual({ char: 'A', color: '#808080' })
|
||
})
|
||
|
||
test('不修改原数组(防御式拷贝)', () => {
|
||
const cells: Cell[] = [{ char: ' ', color: '#5769F7' }]
|
||
const snapshot = cells.map(c => ({ ...c }))
|
||
fadeCells(cells, 0.5)
|
||
expect(cells).toEqual(snapshot)
|
||
})
|
||
|
||
test('TRANSPARENT cell 保持 TRANSPARENT', () => {
|
||
const cells: Cell[] = [
|
||
{ char: ' ', color: TRANSPARENT },
|
||
{ char: ' ', color: '#5769F7' },
|
||
]
|
||
const out = fadeCells(cells, 0.5)
|
||
expect(out[0].color).toBe(TRANSPARENT)
|
||
expect(out[1].color).toBe('#2c357c')
|
||
})
|
||
|
||
test('fade=0 → 所有非 transparent 颜色变 TRANSPARENT', () => {
|
||
const cells: Cell[] = [
|
||
{ char: ' ', color: '#5769F7' },
|
||
{ char: ' ', color: '#1a1f3a' },
|
||
]
|
||
const out = fadeCells(cells, 0)
|
||
expect(out[0].color).toBe(TRANSPARENT)
|
||
expect(out[1].color).toBe(TRANSPARENT)
|
||
})
|
||
})
|