/** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" import { RGBA } from "@opentui/core" import { ManualClock } from "@opentui/core/testing" import { testRender, type JSX } from "@opentui/solid" import { createSignal, Show } from "solid-js" import type { ColorGenerator, SpinnerRenderable } from "../../src/component/spinner-renderable" import "../../src/component/spinner-renderable" const RED = RGBA.fromInts(255, 0, 0) const GREEN = RGBA.fromInts(0, 255, 0) const BLUE = RGBA.fromInts(0, 0, 255) async function mount(spinner: () => JSX.Element) { const clock = new ManualClock() const app = await testRender( () => ( {spinner()} | ), { width: 12, height: 1, clock }, ) await app.renderOnce() return Object.assign(app, { clock, async step(ms: number) { clock.advance(ms) await app.renderOnce() return app.captureCharFrame().split("|")[0]!.trimEnd() }, }) } test.each([ // Ticks share a clock: due every 80 ms from time 0, and up to 40 ms early to share a frame. { name: "advances one frame per elapsed interval", autoplay: true, steps: [39, 1, 80, 160], frames: "abcb" }, { name: "holds the first frame without autoplay", autoplay: false, steps: [80, 160], frames: "aa" }, ])("$name", async (item) => { await using spinner = await mount(() => ) expect(spinner.captureCharFrame()).toStartWith("a|") for (const [index, ms] of item.steps.entries()) expect(await spinner.step(ms)).toBe(item.frames[index]!) }) test("frames reset the glyph; autoplay and visibility stop and resume the ticks", async () => { const [frames, setFrames] = createSignal(["a", "b", "c"]) const [autoplay, setAutoplay] = createSignal(true) const [visible, setVisible] = createSignal(true) await using spinner = await mount(() => ( )) expect(await spinner.step(80)).toBe("b") setFrames(["x", "y"]) expect(await spinner.step(0)).toBe("x") expect(await spinner.step(80)).toBe("y") setAutoplay(false) expect(await spinner.step(200)).toBe("y") setAutoplay(true) // Resumes on the shared clock at the first due time out of reach: 480 ms, so it ticks from 440 ms on. expect(await spinner.step(40)).toBe("y") expect(await spinner.step(40)).toBe("x") // The first tick after a frame that skipped its onUpdate stops the clock. setVisible(false) await spinner.step(1000) await spinner.step(80) expect(spinner.clock.pendingTimerCount).toBe(0) setVisible(true) expect(await spinner.step(39)).toBe("x") expect(await spinner.step(1)).toBe("y") }) test("requests its own frames while leaving the renderer idle, and stops when destroyed", async () => { const [shown, setShown] = createSignal(true) await using spinner = await mount(() => ( )) expect(spinner.renderer.root.liveCount).toBe(0) spinner.clock.advance(80) await spinner.renderer.idle() expect(spinner.captureCharFrame()).toStartWith("b|") expect(spinner.clock.pendingTimerCount).toBe(1) setShown(false) await spinner.renderOnce() expect(spinner.clock.pendingTimerCount).toBe(0) }) test.each([ { frames: ["-"], width: 1 }, { frames: ["-", "abc", "ab"], width: 3 }, { frames: ["界"], width: 2 }, { frames: ["a", "界界"], width: 4 }, { frames: ["👍🏽"], width: 2 }, ])("measures the widest frame by display width: $frames", async (item) => { let node: SpinnerRenderable | undefined await using _ = await mount(() => (node = value)} frames={[...item.frames]} />) expect(node?.width).toBe(item.width) expect(node?.height).toBe(1) }) test("paints per-character generator colors over the parent background", async () => { const calls: number[][] = [] const color: ColorGenerator = (frame, char, frames, chars) => { calls.push([frame, char, frames, chars]) return char === 0 ? RED : "#00ff00" } await using spinner = await mount(() => ) expect(calls.slice(-3)).toEqual([ [0, 0, 2, 3], [0, 1, 2, 3], [0, 2, 2, 3], ]) const spans = spinner.captureSpans().lines[0]!.spans expect(spans.slice(0, 2).map((span) => [span.text, span.fg.equals(RED), span.fg.equals(GREEN)])).toEqual([ ["a", true, false], ["👍🏽b", false, true], ]) expect(spans.slice(0, 2).every((span) => span.bg.equals(BLUE))).toBe(true) }) test("paints a static color and an explicit background", async () => { await using spinner = await mount(() => ) const span = spinner.captureSpans().lines[0]!.spans[0]! expect([span.text, span.fg.equals(RED), span.bg.equals(GREEN)]).toEqual(["ab", true, true]) })