Files
ttui/test/TinyTUI.Tests/Terminal/ConsoleTerminalSessionTests.cs
chuan af9ad109e1 feat: add terminal keyboard protocol negotiation
- negotiate Kitty keyboard protocol during terminal session startup

- fallback to modifyOtherKeys when Kitty protocol is unavailable

- cover protocol startup and restore behavior with terminal session tests
2026-06-04 03:14:05 +08:00

153 lines
5.0 KiB
C#

using TinyTUI.Stdio;
using TinyTUI.Terminal;
using TinyTUI.Tests.TestInfrastructure;
namespace TinyTUI.Tests.Terminal;
public sealed class ConsoleTerminalSessionTests
{
[Fact]
public void StartWritesKittyKeyboardProtocolQuery()
{
var input = new FakeTerminalInput();
var output = new VirtualTerminalOutput();
using var session = new ConsoleTerminalSession(input, output, TimeSpan.FromSeconds(10));
session.Start();
Assert.True(input.Started);
Assert.Contains("\e[?25l", output.Buffer.ToString(), StringComparison.Ordinal);
Assert.Contains("\e[?2004h", output.Buffer.ToString(), StringComparison.Ordinal);
Assert.Contains("\e[>7u\e[?u\e[c", output.Buffer.ToString(), StringComparison.Ordinal);
}
[Fact]
public void KittyFlagsResponseMarksProtocolActiveAndIsNotForwarded()
{
var input = new FakeTerminalInput();
var output = new VirtualTerminalOutput();
using var session = new ConsoleTerminalSession(input, output, TimeSpan.FromSeconds(10));
var received = new List<string>();
session.DataReceived += (_, data) => received.Add(data);
session.Start();
input.RaiseData("\e[?7u");
input.RaiseData("a");
Assert.True(session.KittyProtocolActive);
Assert.False(session.ModifyOtherKeysActive);
Assert.DoesNotContain("\e[>4;2m", output.Buffer.ToString(), StringComparison.Ordinal);
Assert.Equal(["a"], received);
}
[Fact]
public void DeviceAttributesResponseFallsBackToModifyOtherKeys()
{
var input = new FakeTerminalInput();
var output = new VirtualTerminalOutput();
using var session = new ConsoleTerminalSession(input, output, TimeSpan.FromSeconds(10));
session.Start();
input.RaiseData("\e[?1;2c");
Assert.False(session.KittyProtocolActive);
Assert.True(session.ModifyOtherKeysActive);
Assert.Contains("\e[>4;2m", output.Buffer.ToString(), StringComparison.Ordinal);
}
[Fact]
public async Task TimeoutFallsBackToModifyOtherKeys()
{
var input = new FakeTerminalInput();
var output = new VirtualTerminalOutput();
using var session = new ConsoleTerminalSession(input, output, TimeSpan.FromMilliseconds(20));
session.Start();
await WaitUntil(() => session.ModifyOtherKeysActive);
Assert.False(session.KittyProtocolActive);
Assert.Contains("\e[>4;2m", output.Buffer.ToString(), StringComparison.Ordinal);
}
[Fact]
public void StopRestoresKeyboardProtocolsAndTerminalModes()
{
var input = new FakeTerminalInput();
var output = new VirtualTerminalOutput();
using var session = new ConsoleTerminalSession(input, output, TimeSpan.FromSeconds(10));
session.Start();
input.RaiseData("\e[?1;2c");
output.ClearWrites();
session.Stop();
session.Stop();
Assert.False(input.Started);
Assert.False(session.KittyProtocolActive);
Assert.False(session.ModifyOtherKeysActive);
Assert.Equal(1, output.Writes.Count(value => value == "\e[<u"));
Assert.Equal(1, output.Writes.Count(value => value == "\e[>4;0m"));
Assert.Equal(1, output.Writes.Count(value => value == "\e[?2004l"));
Assert.Equal(1, output.Writes.Count(value => value == "\e[?25h"));
}
[Fact]
public void SplitKittyFlagsResponseIsBufferedDuringNegotiation()
{
var input = new FakeTerminalInput();
var output = new VirtualTerminalOutput();
using var session = new ConsoleTerminalSession(input, output, TimeSpan.FromSeconds(10));
var received = new List<string>();
session.DataReceived += (_, data) => received.Add(data);
session.Start();
input.RaiseData("\e[?");
input.RaiseData("7u");
input.RaiseData("x");
Assert.True(session.KittyProtocolActive);
Assert.Equal(["x"], received);
}
private static async Task WaitUntil(Func<bool> condition)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
while (!condition())
{
if (cts.IsCancellationRequested)
throw new TimeoutException("Condition was not met before timeout");
await Task.Delay(5, CancellationToken.None);
}
}
private sealed class FakeTerminalInput : ITerminalInput
{
public TerminalSize CurrentSize { get; private set; } = new(80, 24);
public bool Started { get; private set; }
public event EventHandler<string>? DataReceived;
public event EventHandler<TerminalSize>? Resized;
public void Start() => Started = true;
public void Stop() => Started = false;
public void Dispose()
{
}
public void RaiseData(string data) => DataReceived?.Invoke(this, data);
public void Resize(int columns, int rows)
{
CurrentSize = new TerminalSize(columns, rows);
Resized?.Invoke(this, CurrentSize);
}
}
}