mirror of
https://github.com/aphrody-labs/opentui.git
synced 2026-10-13 21:08:53 +00:00
Negative JavaScript coordinates arrive through Bun FFI as wrapped u32 values. Reject them before signed conversion so off-screen blended writes cannot overflow or address an invalid cell. Fix #1552
480 lines
16 KiB
TypeScript
480 lines
16 KiB
TypeScript
import { describe, expect, it, beforeEach, afterEach } from "bun:test"
|
|
import { OptimizedBuffer } from "./buffer.js"
|
|
import { RGBA } from "./lib/RGBA.js"
|
|
import { NativeImage } from "./image.js"
|
|
|
|
describe("OptimizedBuffer", () => {
|
|
let buffer: OptimizedBuffer
|
|
|
|
beforeEach(() => {
|
|
buffer = OptimizedBuffer.create(20, 5, "unicode", { id: "test-buffer" })
|
|
})
|
|
|
|
afterEach(() => {
|
|
buffer.destroy()
|
|
})
|
|
|
|
it("preserves u32 attributes across per-cell FFI calls", () => {
|
|
const fg = RGBA.fromInts(255, 255, 255)
|
|
const bg = RGBA.fromInts(0, 0, 0)
|
|
const attributes = [0x8000_00ff, 0x4000_01fe, 0x2000_02fd]
|
|
|
|
buffer.setCell(0, 0, "S", fg, bg, attributes[0])
|
|
buffer.setCellWithAlphaBlending(1, 0, "A", fg, bg, attributes[1])
|
|
buffer.drawChar("D".codePointAt(0)!, 2, 0, fg, bg, attributes[2])
|
|
|
|
expect([...buffer.buffers.attributes.slice(0, attributes.length)]).toEqual(attributes)
|
|
})
|
|
|
|
it("clips draws at negative positions", () => {
|
|
// Node FFI rejects a negative u32 argument, so every call here also checks that positions cross FFI as i32.
|
|
const target = OptimizedBuffer.create(3, 2, "unicode", { id: "negative-positions" })
|
|
try {
|
|
const white = RGBA.fromInts(255, 255, 255)
|
|
const black = RGBA.fromInts(0, 0, 0)
|
|
target.clear(black)
|
|
|
|
target.setCell(-1, 0, "S", white, black)
|
|
target.setCellWithAlphaBlending(0, -1, "A", white, black)
|
|
target.drawChar("D".codePointAt(0)!, -1, -1, white, black)
|
|
target.drawSuperSampleBuffer(-1, -1, new Uint8Array(16), 16, "rgba8unorm", 8)
|
|
target.drawPackedBuffer(new Uint8Array(48), 48, -1, -1, 1, 1)
|
|
target.drawText("ABCD", -2, 1, white, black)
|
|
target.fillRect(-1, -1, 2, 2, RGBA.fromInts(255, 0, 0))
|
|
|
|
expect(new TextDecoder().decode(target.getRealCharBytes(true))).toBe(" \nCD \n")
|
|
expect([0, 1, 3].map((cell) => target.buffers.bg[cell * 4] & 0xff)).toEqual([255, 0, 0])
|
|
} finally {
|
|
target.destroy()
|
|
}
|
|
})
|
|
|
|
it("fills nothing for a non-positive extent", () => {
|
|
// Bun wraps a negative u32 argument into a huge extent, so the wrapper must return before the FFI call.
|
|
const target = OptimizedBuffer.create(3, 2, "unicode", { id: "empty-extents" })
|
|
try {
|
|
const red = RGBA.fromInts(255, 0, 0)
|
|
target.clear(RGBA.fromInts(0, 0, 0))
|
|
|
|
target.fillRect(1, 0, -1, 1, red)
|
|
target.fillRect(1, 0, 1, -1, red)
|
|
target.fillRect(1, 0, 0, 1, red)
|
|
|
|
expect([0, 1, 2, 3, 4, 5].map((cell) => target.buffers.bg[cell * 4] & 0xff)).toEqual([0, 0, 0, 0, 0, 0])
|
|
} finally {
|
|
target.destroy()
|
|
}
|
|
})
|
|
|
|
it("draws images as reserved cells with resolved fallback glyphs", () => {
|
|
const image = NativeImage.fromRgba(
|
|
Uint8Array.of(255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255),
|
|
2,
|
|
2,
|
|
)
|
|
try {
|
|
expect(buffer.drawImage(image, 0, 0, 1, 1)).toBe(true)
|
|
const marker = buffer.buffers.char[0]
|
|
expect(marker >>> 30).toBe(1)
|
|
expect(new TextDecoder().decode(buffer.getRealCharBytes())).not.toContain("�")
|
|
buffer.setCell(0, 0, "X", RGBA.fromInts(255, 255, 255), RGBA.fromInts(0, 0, 0))
|
|
expect(buffer.buffers.char[0]).toBe("X".codePointAt(0)!)
|
|
} finally {
|
|
image.dispose()
|
|
}
|
|
})
|
|
|
|
it("retains drawn images until the buffer releases them", () => {
|
|
const image = NativeImage.fromRgba(Uint8Array.of(1, 2, 3, 255), 1, 1)
|
|
let raw: ReturnType<NativeImage["takeRaw"]> | undefined
|
|
try {
|
|
expect(buffer.drawImage(image, 0, 0, 1, 1)).toBe(true)
|
|
expect(() => image.takeRaw()).toThrow("native buffers retain the image")
|
|
|
|
buffer.destroy()
|
|
raw = image.takeRaw()
|
|
expect([...raw.data]).toEqual([1, 2, 3, 255])
|
|
} finally {
|
|
raw?.dispose()
|
|
image.dispose()
|
|
}
|
|
})
|
|
|
|
it("releases drawn images when cleared", () => {
|
|
const image = NativeImage.fromRgba(Uint8Array.of(1, 2, 3, 255), 1, 1)
|
|
let raw: ReturnType<NativeImage["takeRaw"]> | undefined
|
|
try {
|
|
expect(buffer.drawImage(image, 0, 0, 1, 1)).toBe(true)
|
|
expect(() => image.takeRaw()).toThrow("native buffers retain the image")
|
|
|
|
buffer.clear()
|
|
raw = image.takeRaw()
|
|
expect([...raw.data]).toEqual([1, 2, 3, 255])
|
|
} finally {
|
|
raw?.dispose()
|
|
image.dispose()
|
|
}
|
|
})
|
|
|
|
it("rejects invalid image draw geometry before FFI", () => {
|
|
const image = NativeImage.fromRgba(Uint8Array.of(1, 2, 3, 255), 1, 1)
|
|
try {
|
|
expect(() => buffer.drawImage(image, 0, 0, Number.POSITIVE_INFINITY, 1)).toThrow(RangeError)
|
|
expect(() => buffer.drawImage(image, 0, 0, -1, 1)).toThrow(RangeError)
|
|
expect(() => buffer.drawImage(image, 0.5, 0, 1, 1)).toThrow(RangeError)
|
|
expect(() => buffer.drawImage(image, 0, 0, 0x80000000, 1)).toThrow(RangeError)
|
|
expect(() => buffer.drawImage(image, 0x7fffffff, 0, 1, 1)).toThrow(RangeError)
|
|
} finally {
|
|
image.dispose()
|
|
}
|
|
})
|
|
|
|
describe("encodeUnicode", () => {
|
|
it("should encode simple ASCII text", () => {
|
|
const encoded = buffer.encodeUnicode("Hello")
|
|
expect(encoded).not.toBeNull()
|
|
expect(encoded!.data.length).toBe(5)
|
|
expect(encoded!.data[0]).toEqual({ width: 1, char: 72 }) // 'H'
|
|
expect(encoded!.data[1]).toEqual({ width: 1, char: 101 }) // 'e'
|
|
expect(encoded!.data[2]).toEqual({ width: 1, char: 108 }) // 'l'
|
|
expect(encoded!.data[3]).toEqual({ width: 1, char: 108 }) // 'l'
|
|
expect(encoded!.data[4]).toEqual({ width: 1, char: 111 }) // 'o'
|
|
|
|
buffer.freeUnicode(encoded!)
|
|
})
|
|
|
|
it("should encode emoji with correct width", () => {
|
|
const encoded = buffer.encodeUnicode("👋")
|
|
expect(encoded).not.toBeNull()
|
|
expect(encoded!.data.length).toBe(1)
|
|
expect(encoded!.data[0].width).toBe(2)
|
|
// Should be a packed grapheme (has high bit set)
|
|
expect(encoded!.data[0].char).toBeGreaterThan(0x80000000)
|
|
|
|
buffer.freeUnicode(encoded!)
|
|
})
|
|
|
|
it("should encode mixed ASCII and emoji", () => {
|
|
const encoded = buffer.encodeUnicode("Hi 👋 World")
|
|
expect(encoded).not.toBeNull()
|
|
expect(encoded!.data.length).toBe(10) // H, i, space, emoji, space, W, o, r, l, d
|
|
|
|
// Check ASCII chars
|
|
expect(encoded!.data[0].width).toBe(1)
|
|
expect(encoded!.data[0].char).toBe(72) // 'H'
|
|
|
|
// Check emoji
|
|
expect(encoded!.data[3].width).toBe(2)
|
|
expect(encoded!.data[3].char).toBeGreaterThan(0x80000000)
|
|
|
|
buffer.freeUnicode(encoded!)
|
|
})
|
|
|
|
it("should handle empty string", () => {
|
|
const encoded = buffer.encodeUnicode("")
|
|
expect(encoded).not.toBeNull()
|
|
expect(encoded!.data.length).toBe(0)
|
|
|
|
buffer.freeUnicode(encoded!)
|
|
})
|
|
|
|
it("should encode monkey emoji frames and draw in a line", () => {
|
|
const frames = ["🙈 ", "🙈 ", "🙉 ", "🙊 "]
|
|
const fg = RGBA.fromValues(1, 1, 1, 1)
|
|
const bg = RGBA.fromValues(0, 0, 0, 1)
|
|
|
|
buffer.clear(bg)
|
|
|
|
let x = 0
|
|
for (const frame of frames) {
|
|
const encoded = buffer.encodeUnicode(frame)
|
|
expect(encoded).not.toBeNull()
|
|
|
|
for (const encodedChar of encoded!.data) {
|
|
buffer.drawChar(encodedChar.char, x, 0, fg, bg)
|
|
x += encodedChar.width
|
|
}
|
|
|
|
buffer.freeUnicode(encoded!)
|
|
}
|
|
|
|
const frameBytes = buffer.getRealCharBytes(false)
|
|
const frameText = new TextDecoder().decode(frameBytes)
|
|
expect(frameText).toContain("🙈")
|
|
expect(frameText).toContain("🙉")
|
|
expect(frameText).toContain("🙊")
|
|
})
|
|
})
|
|
|
|
describe("drawChar", () => {
|
|
it("should draw a simple ASCII character", () => {
|
|
const fg = RGBA.fromValues(1, 1, 1, 1)
|
|
const bg = RGBA.fromValues(0, 0, 0, 1)
|
|
|
|
buffer.drawChar(72, 0, 0, fg, bg) // 'H'
|
|
|
|
const chars = buffer.buffers.char
|
|
expect(chars[0]).toBe(72)
|
|
})
|
|
|
|
it("should draw encoded characters from encodeUnicode", () => {
|
|
const encoded = buffer.encodeUnicode("Hello")
|
|
expect(encoded).not.toBeNull()
|
|
|
|
const fg = RGBA.fromValues(1, 1, 1, 1)
|
|
const bg = RGBA.fromValues(0, 0, 0, 1)
|
|
|
|
// Draw each character
|
|
for (let i = 0; i < encoded!.data.length; i++) {
|
|
buffer.drawChar(encoded!.data[i].char, i, 0, fg, bg)
|
|
}
|
|
|
|
// Verify buffer content
|
|
const frameBytes = buffer.getRealCharBytes(false)
|
|
const frameText = new TextDecoder().decode(frameBytes)
|
|
expect(frameText).toContain("Hello")
|
|
|
|
buffer.freeUnicode(encoded!)
|
|
})
|
|
|
|
it("should draw emoji using encoded char", () => {
|
|
const encoded = buffer.encodeUnicode("👋")
|
|
expect(encoded).not.toBeNull()
|
|
|
|
const fg = RGBA.fromValues(1, 1, 1, 1)
|
|
const bg = RGBA.fromValues(0, 0, 0, 1)
|
|
|
|
buffer.drawChar(encoded!.data[0].char, 0, 0, fg, bg)
|
|
|
|
const frameBytes = buffer.getRealCharBytes(false)
|
|
const frameText = new TextDecoder().decode(frameBytes)
|
|
expect(frameText).toContain("👋")
|
|
|
|
buffer.freeUnicode(encoded!)
|
|
})
|
|
})
|
|
|
|
describe("snapshot tests with unicode encoding", () => {
|
|
it("should render ASCII text correctly", () => {
|
|
buffer.clear(RGBA.fromValues(0, 0, 0, 1))
|
|
|
|
const encoded = buffer.encodeUnicode("Hello")
|
|
expect(encoded).not.toBeNull()
|
|
|
|
const fg = RGBA.fromValues(1, 1, 1, 1)
|
|
const bg = RGBA.fromValues(0, 0, 0, 1)
|
|
|
|
let x = 0
|
|
for (const encodedChar of encoded!.data) {
|
|
buffer.drawChar(encodedChar.char, x, 0, fg, bg)
|
|
x += encodedChar.width
|
|
}
|
|
|
|
const frameBytes = buffer.getRealCharBytes(true)
|
|
const frameText = new TextDecoder().decode(frameBytes)
|
|
expect(frameText).toMatchSnapshot("ASCII text rendering")
|
|
|
|
buffer.freeUnicode(encoded!)
|
|
})
|
|
|
|
it("should render emoji text correctly", () => {
|
|
buffer.clear(RGBA.fromValues(0, 0, 0, 1))
|
|
|
|
const encoded = buffer.encodeUnicode("Hi 👋 🌍")
|
|
expect(encoded).not.toBeNull()
|
|
|
|
const fg = RGBA.fromValues(1, 1, 1, 1)
|
|
const bg = RGBA.fromValues(0, 0, 0, 1)
|
|
|
|
let x = 0
|
|
for (const encodedChar of encoded!.data) {
|
|
buffer.drawChar(encodedChar.char, x, 0, fg, bg)
|
|
x += encodedChar.width
|
|
}
|
|
|
|
const frameBytes = buffer.getRealCharBytes(true)
|
|
const frameText = new TextDecoder().decode(frameBytes)
|
|
expect(frameText).toMatchSnapshot("Emoji text rendering")
|
|
|
|
buffer.freeUnicode(encoded!)
|
|
})
|
|
|
|
it("should handle multiline text with unicode", () => {
|
|
buffer.clear(RGBA.fromValues(0, 0, 0, 1))
|
|
|
|
const lines = ["Hi 世界", "🌟 Star"]
|
|
const fg = RGBA.fromValues(1, 1, 1, 1)
|
|
const bg = RGBA.fromValues(0, 0, 0, 1)
|
|
|
|
for (let y = 0; y < lines.length; y++) {
|
|
const encoded = buffer.encodeUnicode(lines[y])
|
|
expect(encoded).not.toBeNull()
|
|
|
|
let x = 0
|
|
for (const encodedChar of encoded!.data) {
|
|
buffer.drawChar(encodedChar.char, x, y, fg, bg)
|
|
x += encodedChar.width
|
|
}
|
|
|
|
buffer.freeUnicode(encoded!)
|
|
}
|
|
|
|
const frameBytes = buffer.getRealCharBytes(true)
|
|
const frameText = new TextDecoder().decode(frameBytes)
|
|
expect(frameText).toMatchSnapshot("Multiline unicode rendering")
|
|
})
|
|
|
|
it("should respect character widths in positioning", () => {
|
|
const encoded = buffer.encodeUnicode("A👋B")
|
|
expect(encoded).not.toBeNull()
|
|
|
|
const fg = RGBA.fromValues(1, 1, 1, 1)
|
|
const bg = RGBA.fromValues(0, 0, 0, 1)
|
|
|
|
// 'A' at x=0, emoji at x=1 (width 2), 'B' at x=3
|
|
buffer.drawChar(encoded!.data[0].char, 0, 0, fg, bg) // 'A'
|
|
buffer.drawChar(encoded!.data[1].char, 1, 0, fg, bg) // emoji
|
|
buffer.drawChar(encoded!.data[2].char, 3, 0, fg, bg) // 'B'
|
|
|
|
const frameBytes = buffer.getRealCharBytes(false)
|
|
const frameText = new TextDecoder().decode(frameBytes)
|
|
expect(frameText).toContain("A👋B")
|
|
|
|
buffer.freeUnicode(encoded!)
|
|
})
|
|
})
|
|
|
|
describe("drawChar with alpha blending", () => {
|
|
it("should blend semi-transparent foreground", () => {
|
|
const fg = RGBA.fromValues(1, 0, 0, 0.5)
|
|
const bg = RGBA.fromValues(0, 0, 0, 1)
|
|
|
|
buffer.drawChar(65, 0, 0, fg, bg) // 'A'
|
|
|
|
const fgBuffer = buffer.buffers.fg
|
|
// Foreground alpha is flattened against the final opaque cell background.
|
|
expect(fgBuffer[0] & 0xff).toBe(128)
|
|
expect(fgBuffer[3] & 0xff).toBe(255)
|
|
})
|
|
|
|
it("should blend semi-transparent background", () => {
|
|
buffer.setRespectAlpha(true)
|
|
|
|
const fg = RGBA.fromValues(1, 1, 1, 1)
|
|
const bg = RGBA.fromValues(1, 0, 0, 0.5)
|
|
|
|
buffer.drawChar(65, 0, 0, fg, bg) // 'A'
|
|
|
|
const bgBuffer = buffer.buffers.bg
|
|
// Background should reflect the alpha
|
|
expect(bgBuffer[3] & 0xff).toBeLessThan(255)
|
|
})
|
|
})
|
|
|
|
describe("grapheme pool churn across drawFrameBuffer", () => {
|
|
it("should not crash with WrongGeneration after many grapheme alloc cycles", () => {
|
|
const parent = OptimizedBuffer.create(40, 5, "unicode", { id: "parent" })
|
|
const child = OptimizedBuffer.create(40, 5, "unicode", { id: "child", respectAlpha: true })
|
|
|
|
const fg = RGBA.fromValues(1, 1, 1, 1)
|
|
const bg = RGBA.fromValues(0, 0, 0, 1)
|
|
|
|
for (let cycle = 0; cycle < 50; cycle++) {
|
|
parent.clear(bg)
|
|
|
|
if (cycle % 2 === 0) {
|
|
child.drawText("╭────────────────────────────────────╮", 0, 0, fg, bg)
|
|
child.drawText("│ ◇ Select Files ▫ src/ ▪ file.ts │", 0, 1, fg, bg)
|
|
child.drawText("│ ↑↓ navigate ⏎ select esc close │", 0, 2, fg, bg)
|
|
child.drawText("╰────────────────────────────────────╯", 0, 3, fg, bg)
|
|
} else {
|
|
child.drawText(" Your Name ", 0, 0, fg, bg)
|
|
child.drawText(" John Doe ", 0, 1, fg, bg)
|
|
child.drawText(" ", 0, 2, fg, bg)
|
|
child.drawText(" Select Files ", 0, 3, fg, bg)
|
|
}
|
|
|
|
parent.drawFrameBuffer(0, 0, child)
|
|
|
|
const frameBytes = parent.getRealCharBytes(true)
|
|
const text = new TextDecoder().decode(frameBytes)
|
|
expect(text.length).toBeGreaterThan(0)
|
|
}
|
|
|
|
child.destroy()
|
|
parent.destroy()
|
|
})
|
|
})
|
|
|
|
describe("draw text encoding", () => {
|
|
const white = RGBA.fromInts(255, 255, 255)
|
|
const black = RGBA.fromInts(0, 0, 0)
|
|
const rows = (target: OptimizedBuffer) =>
|
|
new TextDecoder()
|
|
.decode(target.getRealCharBytes(true))
|
|
.split("\n")
|
|
.map((row) => row.trimEnd())
|
|
|
|
it("draws non-string text as TextEncoder converts it", () => {
|
|
buffer.clear(black)
|
|
buffer.drawText(123 as never, 0, 0, white)
|
|
buffer.drawText(["a", "b"] as never, 0, 1, white)
|
|
buffer.drawText(undefined as never, 0, 2, white)
|
|
buffer.drawText(null as never, 0, 3, white)
|
|
expect(rows(buffer).slice(0, 4)).toEqual(["123", "a,b", "", "null"])
|
|
})
|
|
|
|
it("draws only the latest text after longer and multi-byte text", () => {
|
|
buffer.clear(black)
|
|
buffer.drawText("é漢😀 wide", 0, 0, white)
|
|
buffer.drawText("x".repeat(5000), 0, 1, white)
|
|
buffer.drawText("ok", 0, 1, white)
|
|
buffer.drawText("é漢😀", 0, 2, white)
|
|
buffer.drawText("ab", 0, 2, white)
|
|
expect(rows(buffer).slice(0, 3)).toEqual(["é漢😀 wide", "ok" + "x".repeat(18), "ab 😀"])
|
|
})
|
|
|
|
it("draws all of a multi-byte text whose UTF-8 is longer than its UTF-16 length", () => {
|
|
const wide = OptimizedBuffer.create(3000, 1, "unicode", { id: "wide-buffer" })
|
|
try {
|
|
const text = "漢".repeat(1400)
|
|
wide.drawText(text, 0, 0, white)
|
|
expect(rows(wide)[0]).toBe(text)
|
|
} finally {
|
|
wide.destroy()
|
|
}
|
|
})
|
|
|
|
it("converts both box titles before a title's toString can draw", () => {
|
|
const other = OptimizedBuffer.create(12, 1, "unicode", { id: "other-buffer" })
|
|
try {
|
|
other.clear(black)
|
|
buffer.clear(black)
|
|
const bottomTitle = {
|
|
toString() {
|
|
other.drawText("XYZ", 0, 0, white)
|
|
return "BOT"
|
|
},
|
|
}
|
|
buffer.drawBox({
|
|
x: 0,
|
|
y: 0,
|
|
width: 12,
|
|
height: 3,
|
|
border: true,
|
|
borderColor: white,
|
|
backgroundColor: black,
|
|
title: "TOP",
|
|
bottomTitle: bottomTitle as never,
|
|
})
|
|
const [top, , bottom] = rows(buffer)
|
|
expect(top).toContain("TOP")
|
|
expect(bottom).toContain("BOT")
|
|
expect(rows(other)[0]).toBe("XYZ")
|
|
} finally {
|
|
other.destroy()
|
|
}
|
|
})
|
|
})
|
|
})
|