Files
oh-my-pi/packages/tui/test/input.test.ts
T
2026-06-16 18:55:23 +08:00

244 lines
7.9 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, it } from "bun:test";
import { CURSOR_MARKER } from "@oh-my-pi/pi-tui";
import { Input } from "@oh-my-pi/pi-tui/components/input";
import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
import { getIndentation } from "@oh-my-pi/pi-utils";
function renderedWidth(input: Input, width: number): number {
const [line] = input.render(width);
// TUI strips this marker before its width verification; tests should mimic that.
return visibleWidth(line.replaceAll(CURSOR_MARKER, ""));
}
describe("Input component", () => {
const wordLeft = "\x1bb"; // ESC-b (alt+b)
const wordRight = "\x1bf"; // ESC-f (alt+f)
function setupAtEnd(text: string): Input {
const input = new Input();
input.focused = true;
input.setValue(text);
input.handleInput("\x05"); // Ctrl+E (end)
return input;
}
it("moves by CJK and punctuation blocks (backward)", () => {
const text = "天气不错,去散步吧!";
{
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("天气不错,去散步吧|!");
}
{
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("天气不错,|去散步吧!");
}
{
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("天气不错|,去散步吧!");
}
{
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("|天气不错,去散步吧!");
}
});
it("moves by CJK and punctuation blocks (forward)", () => {
const text = "天气不错,去散步吧!";
const input = new Input();
input.focused = true;
input.setValue(text);
input.handleInput("\x01"); // Ctrl+A (start)
input.handleInput(wordRight);
input.handleInput("|");
expect(input.getValue()).toBe("天气不错|,去散步吧!");
});
it("treats NBSP as whitespace for word navigation", () => {
const nbsp = "\u00A0";
const text = `Hola${nbsp}mundo`;
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe(`Hola${nbsp}|mundo`);
});
it("keeps common joiners inside words", () => {
{
const text = "co-operate l’été";
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("co-operate |l’été");
}
{
const text = "co-operate l’été";
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("|co-operate l’été");
}
});
it("recognizes Unicode punctuation as delimiter blocks", () => {
{
const text = "¿Cómo estás? ¡Muy bien!";
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("¿Cómo estás? ¡Muy bien|!");
}
{
const text = "¿Cómo estás? ¡Muy bien!";
const input = setupAtEnd(text);
input.handleInput(wordLeft);
input.handleInput(wordLeft);
input.handleInput("|");
expect(input.getValue()).toBe("¿Cómo estás? ¡Muy |bien!");
}
});
it("does not delete twice when Kitty sends backspace press and release", () => {
setKittyProtocolActive(true);
const input = setupAtEnd("ab");
input.handleInput("\x1b[127u");
expect(input.getValue()).toBe("a");
input.handleInput("\x1b[127;1:3u");
expect(input.getValue()).toBe("a");
setKittyProtocolActive(false);
});
it("inserts keypad digits from Kitty CSI-u input with or without NumLock modifier", () => {
setKittyProtocolActive(true);
const input = setupAtEnd("a");
input.handleInput("\x1b[57407u");
input.handleInput("\x1b[57407;129u");
input.handleInput("\x1b[57404u");
expect(input.getValue()).toBe("a885");
setKittyProtocolActive(false);
});
it("inserts keypad operators from Kitty CSI-u input", () => {
setKittyProtocolActive(true);
const input = setupAtEnd("a");
input.handleInput("\x1b[57410u");
expect(input.getValue()).toBe("a/");
setKittyProtocolActive(false);
});
it("normalizes tabs in buffered bracketed paste using configured indentation", () => {
const input = setupAtEnd("");
input.handleInput("\x1b[200~a\t");
expect(input.getValue()).toBe("");
input.handleInput("b\r\n");
expect(input.getValue()).toBe("");
input.handleInput("c\x1b[201~");
expect(input.getValue()).toBe(`a${" ".repeat(getIndentation())}bc`);
});
it("never renders a line wider than the terminal width (wide chars)", () => {
const input = new Input();
input.focused = true;
// Long wide-script text: string length != terminal cell width.
input.setValue("天气不错,去散步吧!".repeat(50));
input.handleInput("\x05"); // Ctrl+E (end)
const width = 40;
expect(renderedWidth(input, width)).toBeLessThanOrEqual(width);
});
it("normalizes NFD Korean pastes (macOS Finder drag-drop) to NFC", () => {
// macOS Finder drag-drops file paths in NFD (decomposed Unicode).
// Korean syllable `화` is U+D654 (1 char, 2 cells) in NFC, but
// ᄒ(U+1112) + ᅪ(U+116A) (2 chars, 3 cells per Bun.stringWidth) in NFD.
// Without normalization, the cursor lands `(NFD cells - NFC cells)`
// past the visible filename — the documented "cursor displacement"
// bug after drag-dropping a Korean filename.
const input = new Input();
input.focused = true;
const nfcPath = "/Users/leo/Downloads/화면.mov";
const nfdPath = nfcPath.normalize("NFD");
// Sanity: ensure our test fixture really differs between NFC and NFD.
expect(nfdPath).not.toBe(nfcPath);
expect(nfdPath.length).toBeGreaterThan(nfcPath.length);
// Simulate macOS bracketed-paste drop of an NFD path.
input.handleInput(`\x1b[200~${nfdPath}\x1b[201~`);
// Stored value must be NFC — no more NFD characters in the buffer.
expect(input.getValue()).toBe(nfcPath);
});
it("NFC paste: cursor column matches visible cells (no displacement)", () => {
// Regression guard for the "cursor floats past the filename" bug.
// After paste, the cursor must be at a column == visibleWidth(value)
// (plus 2 for the "> " prompt prefix).
const input = new Input();
input.focused = true;
const nfdPath = "/Users/leo/화면\\ 기록.mov".normalize("NFD");
input.handleInput(`\x1b[200~${nfdPath}\x1b[201~`);
const [line] = input.render(120);
const markerIdx = line.indexOf(CURSOR_MARKER);
expect(markerIdx).toBeGreaterThanOrEqual(0);
const col = visibleWidth(line.slice(0, markerIdx));
// Prompt "> " (2 cells) + value width in NFC (matches terminal rendering).
const expectedCol = 2 + visibleWidth(input.getValue());
expect(col).toBe(expectedCol);
});
it("terminal cursor mode emits marker without inverse-video software cursor", () => {
const input = new Input();
input.focused = true;
input.setUseTerminalCursor(true);
input.setValue("abc");
input.handleInput("\x01"); // Ctrl+A (start)
const [line] = input.render(20);
expect(line).toContain(CURSOR_MARKER);
expect(line).not.toContain("\x1b[7m");
expect(line.replaceAll(CURSOR_MARKER, "")).toContain("abc");
expect(input.getUseTerminalCursor()).toBe(true);
});
it("pasteText absorbs a payload from a non-bracketed transport (kitty OSC 5522)", () => {
// Regression for #2127: when kitty's enhanced clipboard read delivers the
// API key directly via `pasteText`, the modal Input must capture it just
// like a bracketed paste — newlines stripped, value inserted, cursor at end.
const input = setupAtEnd("");
input.pasteText("sk-line1\nsk-line2\r\nsk-line3");
expect(input.getValue()).toBe("sk-line1sk-line2sk-line3");
});
});