// Copyright 2026 The TCell Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package vt
import (
"testing"
"unicode/utf8"
"github.com/clipperhouse/uax29/v2/graphemes"
"github.com/gdamore/tcell/v3/color"
)
func TestCursorStyleHelpers(t *testing.T) {
t.Parallel()
tests := []struct {
name string
style CursorStyle
visible bool
blinking bool
}{
{name: "steady block", style: SteadyBlock, visible: true, blinking: false},
{name: "steady bar", style: SteadyBar, visible: true, blinking: false},
{name: "steady underline", style: SteadyUnderline, visible: true, blinking: false},
{name: "blinking block", style: BlinkingBlock, visible: true, blinking: true},
{name: "hidden blink", style: BlinkingBar.Hide(), visible: false, blinking: true},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := tc.style.IsVisible(); got != tc.visible {
t.Fatalf("IsVisible() = %v, want %v", got, tc.visible)
}
if got := tc.style.IsBlinking(); got != tc.blinking {
t.Fatalf("IsBlinking() = %v, want %v", got, tc.blinking)
}
if got := tc.style.Hide().IsVisible(); got {
t.Fatalf("Hide() should clear visibility")
}
if got := tc.style.Show().IsVisible(); !got {
t.Fatalf("Show() should set visibility")
}
if got := tc.style.Blink().IsBlinking(); !got {
t.Fatalf("Blink() should set blinking")
}
if got := tc.style.Steady().IsBlinking(); got {
t.Fatalf("Steady() should clear blinking")
}
})
}
}
func TestModeFormatters(t *testing.T) {
t.Parallel()
tests := []struct {
name string
pm PrivateMode
enable string
disable string
query string
replyOn string
replyOff string
ansi AnsiMode
ansiEnable string
ansiDisable string
ansiQuery string
ansiReply string
}{
{
name: "auto margin",
pm: PmAutoMargin,
enable: "\x1b[?7h",
disable: "\x1b[?7l",
query: "\x1b[?7$p",
replyOn: "\x1b[?7;1$y",
replyOff: "\x1b[?7;2$y",
ansi: AmNewLineMode,
ansiEnable: "\x1b[20h",
ansiDisable: "\x1b[20l",
ansiQuery: "\x1b[20$p",
ansiReply: "\x1b[20;1$y",
},
}
for _, tc := range tests {
if got := tc.pm.Enable(); got != tc.enable {
t.Fatalf("PrivateMode.Enable() = %q, want %q", got, tc.enable)
}
if got := tc.pm.Disable(); got != tc.disable {
t.Fatalf("PrivateMode.Disable() = %q, want %q", got, tc.disable)
}
if got := tc.pm.Query(); got != tc.query {
t.Fatalf("PrivateMode.Query() = %q, want %q", got, tc.query)
}
if got := tc.pm.Reply(ModeOn); got != tc.replyOn {
t.Fatalf("PrivateMode.Reply(ModeOn) = %q, want %q", got, tc.replyOn)
}
if got := tc.pm.Reply(ModeOff); got != tc.replyOff {
t.Fatalf("PrivateMode.Reply(ModeOff) = %q, want %q", got, tc.replyOff)
}
if got := tc.ansi.Enable(); got != tc.ansiEnable {
t.Fatalf("AnsiMode.Enable() = %q, want %q", got, tc.ansiEnable)
}
if got := tc.ansi.Disable(); got != tc.ansiDisable {
t.Fatalf("AnsiMode.Disable() = %q, want %q", got, tc.ansiDisable)
}
if got := tc.ansi.Query(); got != tc.ansiQuery {
t.Fatalf("AnsiMode.Query() = %q, want %q", got, tc.ansiQuery)
}
if got := tc.ansi.Reply(ModeOn); got != tc.ansiReply {
t.Fatalf("AnsiMode.Reply(ModeOn) = %q, want %q", got, tc.ansiReply)
}
}
}
func TestModeStatusChangeable(t *testing.T) {
t.Parallel()
cases := []struct {
status ModeStatus
want bool
}{
{ModeNA, false},
{ModeOn, true},
{ModeOff, true},
{ModeOnLocked, false},
{ModeOffLocked, false},
}
for _, tc := range cases {
if got := tc.status.Changeable(); got != tc.want {
t.Fatalf("Changeable(%v) = %v, want %v", tc.status, got, tc.want)
}
}
}
func TestMockBackendResizeAndBells(t *testing.T) {
t.Parallel()
mb := NewMockBackend(MockOptSize{X: 2, Y: 2}, MockOptColors(0)).(*mockBackend)
if got := mb.Bells(); got != 0 {
t.Fatalf("initial bells = %d, want 0", got)
}
mb.Beep()
mb.Beep()
if got := mb.Bells(); got != 2 {
t.Fatalf("bells after beep = %d, want 2", got)
}
mb.Put(Coord{X: 0, Y: 0}, Cell{C: "x", S: BaseStyle, W: 1})
mb.SetPosition(Coord{X: 1, Y: 1})
mb.SetSize(Coord{X: 3, Y: 1})
if got := mb.GetSize(); got != (Coord{X: 3, Y: 1}) {
t.Fatalf("GetSize() = %v, want {3 1}", got)
}
if got := mb.GetCell(Coord{X: 0, Y: 0}); got.C != "x" || got.W != 1 {
t.Fatalf("resized cell preserved incorrectly: %#v", got)
}
if got := mb.GetCell(Coord{X: 2, Y: 0}); got.S != BaseStyle {
t.Fatalf("new cell style = %#v, want BaseStyle", got.S)
}
}
func TestStringCacheHelpers(t *testing.T) {
t.Parallel()
mb := NewMockBackend(MockOptSize{X: 4, Y: 1}, MockOptColors(0)).(*mockBackend)
em := NewEmulator(mb).(*emulator)
if got := em.runeString('a'); got != "a" {
t.Fatalf("runeString(ascii) = %q, want %q", got, "a")
}
if got := em.runeString('\u03c0'); got != "π" {
t.Fatalf("runeString(non-ascii) = %q, want %q", got, "π")
}
if got := em.runeString('\u03c0'); got != "π" {
t.Fatalf("runeString cache hit returned %q, want %q", got, "π")
}
if got := em.clusterString([]byte("e\u0301")); got != "e\u0301" {
t.Fatalf("clusterString(combining) = %q, want %q", got, "e\u0301")
}
if got := em.clusterString([]byte("e\u0301")); got != "e\u0301" {
t.Fatalf("clusterString cache hit returned %q, want %q", got, "e\u0301")
}
}
func TestEmulatorBellDispatch(t *testing.T) {
t.Parallel()
mb := NewMockBackend(MockOptSize{X: 4, Y: 1}, MockOptColors(0)).(*mockBackend)
em := NewEmulator(mb).(*emulator)
em.style = BaseStyle.WithFg(color.White).WithBg(color.Black)
em.defaultStyle = em.style
em.beep()
if got := mb.Bells(); got != 1 {
t.Fatalf("bell count = %d, want 1", got)
}
}
func TestShouldCheckGrapheme(t *testing.T) {
t.Parallel()
cases := []struct {
name string
prev byte
r rune
want bool
}{
{name: "crlf", prev: '\r', r: '\n', want: true},
{name: "ascii false", prev: 'a', r: 'b', want: false},
{name: "mark", prev: 'e', r: '\u0301', want: true},
{name: "zwj", prev: 'x', r: '\u200d', want: true},
{name: "vs16", prev: 'x', r: '\uFE0F', want: true},
{name: "supplementary vs", prev: 'x', r: '\U000E0100', want: true},
{name: "plain non-ascii", prev: 'x', r: 'π', want: false},
// runes Go's unicode tables get wrong, and the segmenter does not
{name: "zwnj", prev: 'a', r: '', want: true},
{name: "emoji modifier", prev: 'a', r: '\U0001F3FB', want: true},
{name: "unicode 16 mark", prev: 'a', r: '', want: true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := shouldCheckGrapheme(tc.prev, tc.r); got != tc.want {
t.Fatalf("shouldCheckGrapheme(%q, %q) = %v, want %v", tc.prev, tc.r, got, tc.want)
}
})
}
}
// TestGraphemeJoinerBlocks re-derives graphemeJoinerBlocks from the segmenter.
// shouldCheckGrapheme is only allowed to skip the segmenter for runes the
// segmenter would never join, so a stale table after a uax29 upgrade has to
// fail here rather than silently split clusters again.
func TestGraphemeJoinerBlocks(t *testing.T) {
t.Parallel()
buf := make([]byte, 0, 1+utf8.UTFMax)
joiners, skipped := 0, 0
for r := rune(utf8.RuneSelf); r <= utf8.MaxRune; r++ {
if !utf8.ValidRune(r) {
continue
}
buf = utf8.AppendRune(append(buf[:0], 'e'), r)
iter := graphemes.FromBytes(buf)
joins := iter.Next() && len(iter.Value()) == len(buf)
check := shouldCheckGrapheme('e', r)
if joins {
joiners++
if !check {
t.Fatalf("U+%04X joins an ASCII base but graphemeJoinerBlocks skips it", r)
}
}
if !check {
skipped++
}
}
// Guard against a table of all ones, which would pass the check above
// while making the fast path pointless.
if joiners == 0 || skipped < joiners {
t.Fatalf("implausible table: %d joiners, %d runes skipped", joiners, skipped)
}
}
func TestPutRunePaths(t *testing.T) {
newEm := func(width int) *emulator {
em := NewEmulator(NewMockBackend(MockOptSize{X: Col(width), Y: 1}, MockOptColors(0))).(*emulator)
em.style = BaseStyle.WithFg(color.White).WithBg(color.Black)
em.defaultStyle = em.style
em.localModes[PmGraphemeClusters] = ModeOn
em.localModes[PmAutoMargin] = ModeOn
em.cells[0].S = em.style
em.cells[0].W = 1
return em
}
t.Run("ascii falls through", func(t *testing.T) {
em := newEm(4)
em.cells[0].C = "a"
em.lastIndex = 1
em.setPosition(Coord{X: 1, Y: 0})
em.putRune('b')
if got := em.cells[1].C; got != "b" {
t.Fatalf("cell[1].C = %q, want %q", got, "b")
}
if got := em.cells[0].C; got != "a" {
t.Fatalf("cell[0].C = %q, want %q", got, "a")
}
})
t.Run("combining mark extends cluster", func(t *testing.T) {
em := newEm(4)
em.cells[0].C = "e"
em.lastIndex = 1
em.setPosition(Coord{X: 1, Y: 0})
em.putRune('\u0301')
if got := em.cells[0].C; got != "e\u0301" {
t.Fatalf("cell[0].C = %q, want %q", got, "e\u0301")
}
if got := em.cells[0].W; got != 1 {
t.Fatalf("cell[0].W = %d, want 1", got)
}
})
t.Run("wide grapheme clears next cell", func(t *testing.T) {
em := newEm(4)
em.cells[0].C = "\u2764"
em.cells[1].C = "x"
em.cells[1].S = em.style
em.cells[1].W = 1
em.lastIndex = 1
em.setPosition(Coord{X: 1, Y: 0})
em.putRune('\uFE0F')
if got := em.cells[0].W; got != 2 {
t.Fatalf("cell[0].W = %d, want 2", got)
}
if got := em.cells[1].C; got != "" || em.cells[1].W != 0 {
t.Fatalf("cell[1] not cleared: %#v", em.cells[1])
}
})
t.Run("auto wrap when width reaches margin", func(t *testing.T) {
em := newEm(2)
em.cells[0].C = "a"
em.lastIndex = 1
em.setPosition(Coord{X: 1, Y: 0})
em.putRune('宽')
if !em.autoWrap {
t.Fatalf("autoWrap = false, want true")
}
})
t.Run("grapheme extension at margin preserves wrap", func(t *testing.T) {
em := newEm(2)
em.cells[1].C = "\u2764"
em.cells[1].S = em.style
em.cells[1].W = 1
em.lastIndex = 2
em.setPosition(Coord{X: 1, Y: 0})
em.putRune('\uFE0F')
if !em.autoWrap {
t.Fatalf("autoWrap = false, want true")
}
if got := em.getPosition(); got.X != 1 {
t.Fatalf("cursor X = %d, want 1", got.X)
}
})
}