mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 22:25:02 +03:00
197 lines
7.0 KiB
TypeScript
197 lines
7.0 KiB
TypeScript
import { expect, test } from "bun:test"
|
|
/** @jsxImportSource @opentui/solid */
|
|
import { RGBA } from "@opentui/core"
|
|
import { ManualClock } from "@opentui/core/testing"
|
|
import { testRender } from "@opentui/solid"
|
|
import { createSignal } from "solid-js"
|
|
import {
|
|
TabPulse,
|
|
blendTabPulseColor,
|
|
completionPulseOpacity,
|
|
glowIgnitionLevel,
|
|
tabFlashIntensity,
|
|
unreadGlowIntensity,
|
|
} from "../../src/component/tab-pulse"
|
|
import { tint } from "../../src/theme/color"
|
|
|
|
const BACKGROUND = RGBA.fromHex("#101010")
|
|
|
|
async function mount(
|
|
props: () => { active: boolean; enabled?: boolean; glow?: boolean; onLevel?: (level: number) => void },
|
|
) {
|
|
const clock = new ManualClock()
|
|
const app = await testRender(
|
|
() => (
|
|
<TabPulse
|
|
enabled={props().enabled}
|
|
active={props().active}
|
|
glow={props().glow}
|
|
color={RGBA.fromHex("#ffcc00")}
|
|
backgroundColor={BACKGROUND}
|
|
onLevel={props().onLevel}
|
|
/>
|
|
),
|
|
// Uncapped, a requested frame never waits on a clock timer, so idle() in run() returns before the next step.
|
|
{ width: 10, height: 1, clock, maxFps: Number.POSITIVE_INFINITY },
|
|
)
|
|
let frames = 0
|
|
app.renderer.on("frame", () => frames++)
|
|
await app.renderOnce()
|
|
return Object.assign(app, {
|
|
clock,
|
|
/** Advances the clock in 5 ms steps beside another animation that ticks every `other` ms, and counts frames.
|
|
* Each step waits until the requested frame has drawn, so frames land on the step that requests them. */
|
|
async run(ms: number, other = 0) {
|
|
frames = 0
|
|
const stop = other ? app.renderer.onAnimationTick(1000 / other, () => app.renderer.requestRender()) : undefined
|
|
for (let elapsed = 5; elapsed <= ms; elapsed += 5) {
|
|
clock.advance(5)
|
|
await app.renderer.idle()
|
|
}
|
|
stop?.()
|
|
return frames
|
|
},
|
|
colors() {
|
|
return app.captureSpans().lines[0]!.spans.flatMap((span) => Array(span.width).fill(span.bg.buffer.join()))
|
|
},
|
|
})
|
|
}
|
|
|
|
test("a disabled pulse stays idle when it becomes active", async () => {
|
|
const [active, setActive] = createSignal(false)
|
|
await using pulse = await mount(() => ({ active: active(), enabled: false }))
|
|
setActive(true)
|
|
expect(await pulse.run(500)).toBe(1)
|
|
expect(pulse.renderer.root.liveCount).toBe(0)
|
|
expect(pulse.clock.pendingTimerCount).toBe(0)
|
|
})
|
|
|
|
test("an attention glow becomes idle after ignition", async () => {
|
|
await using pulse = await mount(() => ({ active: false, glow: true }))
|
|
expect(pulse.clock.pendingTimerCount).toBe(1)
|
|
await pulse.run(650)
|
|
expect(pulse.clock.pendingTimerCount).toBe(0)
|
|
expect(await pulse.run(500)).toBe(0)
|
|
})
|
|
|
|
test.each([
|
|
{ name: "alone", other: 0, frames: 20 },
|
|
{ name: "beside a 25 fps animation", other: 40, frames: 25 },
|
|
{ name: "beside a 50 fps animation", other: 20, frames: 50 },
|
|
])("a running pulse adds frames only up to 20 fps, $name", async (item) => {
|
|
await using pulse = await mount(() => ({ active: true }))
|
|
expect(await pulse.run(1000, item.other)).toBe(item.frames)
|
|
expect(pulse.renderer.root.liveCount).toBe(0)
|
|
})
|
|
|
|
test("a running pulse changes its level at most 20 times a second while the renderer draws at 50 fps", async () => {
|
|
const levels: number[] = []
|
|
await using pulse = await mount(() => ({ active: true, onLevel: (level) => levels.push(level) }))
|
|
expect(await pulse.run(2000, 20)).toBe(100)
|
|
expect(levels.length).toBeGreaterThan(10)
|
|
expect(levels.length).toBeLessThanOrEqual(40)
|
|
})
|
|
|
|
// Ticks carry the elapsed time, so the sweep is where the clock says when both tick at the same time.
|
|
test("the sweep shows the same colors at the same time at any frame rate", async () => {
|
|
await using slow = await mount(() => ({ active: true }))
|
|
await using fast = await mount(() => ({ active: true }))
|
|
for (const ms of [400, 600, 1400, 2800]) {
|
|
await slow.run(ms)
|
|
await fast.run(ms, 40)
|
|
await slow.renderOnce()
|
|
await fast.renderOnce()
|
|
expect(slow.colors()).toEqual(fast.colors())
|
|
expect(new Set(slow.colors()).size).toBeGreaterThan(1)
|
|
}
|
|
})
|
|
|
|
test("a pulse stops requesting frames when the run ends", async () => {
|
|
const [active, setActive] = createSignal(true)
|
|
await using pulse = await mount(() => ({ active: active() }))
|
|
await pulse.run(1000)
|
|
setActive(false)
|
|
await pulse.run(1000)
|
|
expect(pulse.clock.pendingTimerCount).toBe(0)
|
|
expect(await pulse.run(1000)).toBe(0)
|
|
expect(new Set(pulse.colors()).size).toBe(1)
|
|
})
|
|
|
|
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
|
expect(completionPulseOpacity(0)).toBe(0)
|
|
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
|
|
expect(completionPulseOpacity(0.12)).toBe(1)
|
|
expect(completionPulseOpacity(0.56)).toBeCloseTo(0.5)
|
|
expect(completionPulseOpacity(1)).toBe(0)
|
|
})
|
|
|
|
test("glow ignition overshoots the resting level and settles back to it", () => {
|
|
expect(glowIgnitionLevel(0)).toBe(0)
|
|
expect(glowIgnitionLevel(0.3)).toBeCloseTo(1.5)
|
|
expect(glowIgnitionLevel(0.6)).toBeGreaterThan(1)
|
|
expect(glowIgnitionLevel(1)).toBe(1)
|
|
})
|
|
|
|
test("unread glow peaks behind the tab number and fades to the normal background", () => {
|
|
const intensities = Array.from({ length: 22 }, (_, index) => unreadGlowIntensity(index, 22))
|
|
|
|
expect(intensities[0]).toBe(1)
|
|
expect(intensities[1]).toBe(1)
|
|
expect(intensities[2]).toBeLessThan(1)
|
|
expect(intensities.slice(1)).toEqual(intensities.slice(1).sort((a, b) => b - a))
|
|
expect(intensities[13]).toBe(0)
|
|
expect(intensities.at(-1)).toBe(0)
|
|
})
|
|
|
|
test("unread glow reaches the normal background on compact tabs", () => {
|
|
expect(unreadGlowIntensity(0, 8)).toBe(1)
|
|
expect(unreadGlowIntensity(7, 8)).toBe(0)
|
|
})
|
|
|
|
test("tab flash holds behind the shortcut then feathers to the background", () => {
|
|
const intensities = Array.from({ length: 10 }, (_, index) => tabFlashIntensity(index, 8))
|
|
|
|
expect(intensities[0]).toBe(1)
|
|
expect(intensities[1]).toBe(1)
|
|
expect(intensities[2]).toBeLessThan(1)
|
|
expect(intensities.slice(1)).toEqual(intensities.slice(1).sort((a, b) => b - a))
|
|
expect(intensities.at(-1)).toBe(0)
|
|
})
|
|
|
|
test("reuses a color while preserving the original glow and pulse blend stages", () => {
|
|
const output = RGBA.fromInts(0, 0, 0)
|
|
const background = RGBA.fromHex("#1a1b26")
|
|
const glowColor = RGBA.fromHex("#82aaff")
|
|
const runningColor = RGBA.fromHex("#c8d3f5")
|
|
const flashColor = RGBA.fromHex("#e2e8fb")
|
|
const completionColor = RGBA.fromHex("#ff9e64")
|
|
|
|
for (const glow of [0, 0.08, 0.16]) {
|
|
for (const running of [0, 0.01, 0.07, 0.14]) {
|
|
for (const flash of [0, 0.05, 0.1]) {
|
|
for (const completion of [0, 0.03, 0.09, 0.18]) {
|
|
blendTabPulseColor(
|
|
output,
|
|
background,
|
|
glowColor,
|
|
runningColor,
|
|
flashColor,
|
|
completionColor,
|
|
glow,
|
|
running,
|
|
flash,
|
|
completion,
|
|
)
|
|
expect(output.buffer).toEqual(
|
|
tint(
|
|
tint(tint(tint(background, glowColor, glow), runningColor, running), flashColor, flash),
|
|
completionColor,
|
|
completion,
|
|
).buffer,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|