Files
claude-code/src/components/EffortPanel/__tests__/rippleAnimation.test.ts
claude-code-best e637b4f6aa feat(effort): 波纹 v4 — 平滑波 + 全色环旋转 + 淡入淡出 + 宽度自适应
- 波函数改 (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>
2026-06-14 15:52:01 +08:00

502 lines
16 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.
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', () => {
// 在一个周期内12000mshueShift 应单调递增
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('周期 12000mstime=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.5time 推进后扫过波峰/波谷', () => {
// v5 平滑波dist=0time=0 时 phase=0sin(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=65time=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 但保留底层 colorcolor 未指定时)', () => {
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 → TRANSPARENTcell 不渲染)', () => {
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)
})
})