Files
clide/test/terminal/escape/parser_test.dart
T
jpmschweitzerandClaude Fable 5 c3dd1d3e3f capture CSI intermediate bytes; stop bare-final mis-dispatch (T-123)
_consumeCsi silently discarded intermediate bytes (0x20-0x2f), so an
intermediate-bearing sequence dispatched on its bare final byte —
`CSI 5 SP @` (VT420 scroll-left) ran as "insert 5 blank characters",
and `CSI Ps SP q` (DECSCUSR) could collide with any future bare-q
handler. The parser now records intermediates on the CSI scratch
object and routes any sequence carrying them to unknownCSI, since no
intermediate form is implemented yet.

Implementing DECSCUSR itself (cursor shape + renderer support) is
filed as T-397.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:55:58 +02:00

875 lines
34 KiB
Dart

/// Pure-Dart tests for EscapeParser + EscapeEmitter.
///
/// Drives a recording EscapeHandler through every reachable dispatch
/// path in the parser: SBC controls, ESC escapes, CSI sequences (with
/// and without params, with and without prefixes), SGR styling
/// (incl. 256-colour and 24-bit RGB), DEC private modes, OSC, window
/// manipulation, and incomplete-sequence rollback.
library;
import 'package:clide/src/terminal/src/core/escape/emitter.dart';
import 'package:clide/src/terminal/src/core/escape/handler.dart';
import 'package:clide/src/terminal/src/core/escape/parser.dart';
import 'package:clide/src/terminal/src/core/mouse/mode.dart';
import 'package:test/test.dart';
class _Call {
const _Call(this.name, this.args);
final String name;
final List<Object?> args;
@override
String toString() => '$name(${args.join(", ")})';
}
class _RecordingHandler implements EscapeHandler {
final calls = <_Call>[];
void clear() => calls.clear();
Iterable<_Call> named(String n) => calls.where((c) => c.name == n);
_Call lastCallNamed(String n) => named(n).last;
@override
void writeChar(int char) => calls.add(_Call("writeChar", [char]));
@override
void bell() => calls.add(_Call("bell", const []));
@override
void backspaceReturn() => calls.add(_Call("backspaceReturn", const []));
@override
void tab() => calls.add(_Call("tab", const []));
@override
void lineFeed() => calls.add(_Call("lineFeed", const []));
@override
void carriageReturn() => calls.add(_Call("carriageReturn", const []));
@override
void shiftOut() => calls.add(_Call("shiftOut", const []));
@override
void shiftIn() => calls.add(_Call("shiftIn", const []));
@override
void unknownSBC(int char) => calls.add(_Call("unknownSBC", [char]));
@override
void saveCursor() => calls.add(_Call("saveCursor", const []));
@override
void restoreCursor() => calls.add(_Call("restoreCursor", const []));
@override
void index() => calls.add(_Call("index", const []));
@override
void nextLine() => calls.add(_Call("nextLine", const []));
@override
void setTapStop() => calls.add(_Call("setTapStop", const []));
@override
void reverseIndex() => calls.add(_Call("reverseIndex", const []));
@override
void designateCharset(int charset, int name) => calls.add(_Call("designateCharset", [charset, name]));
@override
void unkownEscape(int char) => calls.add(_Call("unkownEscape", [char]));
@override
void repeatPreviousCharacter(int n) => calls.add(_Call("repeatPreviousCharacter", [n]));
@override
void setCursor(int x, int y) => calls.add(_Call("setCursor", [x, y]));
@override
void setCursorX(int x) => calls.add(_Call("setCursorX", [x]));
@override
void setCursorY(int y) => calls.add(_Call("setCursorY", [y]));
@override
void sendPrimaryDeviceAttributes() => calls.add(_Call("sendPrimaryDeviceAttributes", const []));
@override
void clearTabStopUnderCursor() => calls.add(_Call("clearTabStopUnderCursor", const []));
@override
void clearAllTabStops() => calls.add(_Call("clearAllTabStops", const []));
@override
void moveCursorX(int offset) => calls.add(_Call("moveCursorX", [offset]));
@override
void moveCursorY(int n) => calls.add(_Call("moveCursorY", [n]));
@override
void sendSecondaryDeviceAttributes() => calls.add(_Call("sendSecondaryDeviceAttributes", const []));
@override
void sendTertiaryDeviceAttributes() => calls.add(_Call("sendTertiaryDeviceAttributes", const []));
@override
void sendOperatingStatus() => calls.add(_Call("sendOperatingStatus", const []));
@override
void sendCursorPosition() => calls.add(_Call("sendCursorPosition", const []));
@override
void setMargins(int i, [int? bottom]) => calls.add(_Call("setMargins", [i, bottom]));
@override
void cursorNextLine(int amount) => calls.add(_Call("cursorNextLine", [amount]));
@override
void cursorPrecedingLine(int amount) => calls.add(_Call("cursorPrecedingLine", [amount]));
@override
void eraseDisplayBelow() => calls.add(_Call("eraseDisplayBelow", const []));
@override
void eraseDisplayAbove() => calls.add(_Call("eraseDisplayAbove", const []));
@override
void eraseDisplay() => calls.add(_Call("eraseDisplay", const []));
@override
void eraseScrollbackOnly() => calls.add(_Call("eraseScrollbackOnly", const []));
@override
void eraseLineRight() => calls.add(_Call("eraseLineRight", const []));
@override
void eraseLineLeft() => calls.add(_Call("eraseLineLeft", const []));
@override
void eraseLine() => calls.add(_Call("eraseLine", const []));
@override
void insertLines(int amount) => calls.add(_Call("insertLines", [amount]));
@override
void deleteLines(int amount) => calls.add(_Call("deleteLines", [amount]));
@override
void deleteChars(int amount) => calls.add(_Call("deleteChars", [amount]));
@override
void scrollUp(int amount) => calls.add(_Call("scrollUp", [amount]));
@override
void scrollDown(int amount) => calls.add(_Call("scrollDown", [amount]));
@override
void eraseChars(int amount) => calls.add(_Call("eraseChars", [amount]));
@override
void insertBlankChars(int amount) => calls.add(_Call("insertBlankChars", [amount]));
@override
void unknownCSI(int finalByte) => calls.add(_Call("unknownCSI", [finalByte]));
@override
void setInsertMode(bool enabled) => calls.add(_Call("setInsertMode", [enabled]));
@override
void setLineFeedMode(bool enabled) => calls.add(_Call("setLineFeedMode", [enabled]));
@override
void setUnknownMode(int mode, bool enabled) => calls.add(_Call("setUnknownMode", [mode, enabled]));
@override
void setCursorKeysMode(bool enabled) => calls.add(_Call("setCursorKeysMode", [enabled]));
@override
void setReverseDisplayMode(bool enabled) => calls.add(_Call("setReverseDisplayMode", [enabled]));
@override
void setOriginMode(bool enabled) => calls.add(_Call("setOriginMode", [enabled]));
@override
void setColumnMode(bool enabled) => calls.add(_Call("setColumnMode", [enabled]));
@override
void setAutoWrapMode(bool enabled) => calls.add(_Call("setAutoWrapMode", [enabled]));
@override
void setMouseMode(MouseMode mode) => calls.add(_Call("setMouseMode", [mode]));
@override
void setCursorBlinkMode(bool enabled) => calls.add(_Call("setCursorBlinkMode", [enabled]));
@override
void setCursorVisibleMode(bool enabled) => calls.add(_Call("setCursorVisibleMode", [enabled]));
@override
void useAltBuffer() => calls.add(_Call("useAltBuffer", const []));
@override
void useMainBuffer() => calls.add(_Call("useMainBuffer", const []));
@override
void clearAltBuffer() => calls.add(_Call("clearAltBuffer", const []));
@override
void setAppKeypadMode(bool enabled) => calls.add(_Call("setAppKeypadMode", [enabled]));
@override
void setReportFocusMode(bool enabled) => calls.add(_Call("setReportFocusMode", [enabled]));
@override
void setMouseReportMode(MouseReportMode mode) => calls.add(_Call("setMouseReportMode", [mode]));
@override
void setAltBufferMouseScrollMode(bool enabled) => calls.add(_Call("setAltBufferMouseScrollMode", [enabled]));
@override
void setBracketedPasteMode(bool enabled) => calls.add(_Call("setBracketedPasteMode", [enabled]));
@override
void setUnknownDecMode(int mode, bool enabled) => calls.add(_Call("setUnknownDecMode", [mode, enabled]));
@override
void resize(int cols, int rows) => calls.add(_Call("resize", [cols, rows]));
@override
void sendSize() => calls.add(_Call("sendSize", const []));
@override
void resetCursorStyle() => calls.add(_Call("resetCursorStyle", const []));
@override
void setCursorBold() => calls.add(_Call("setCursorBold", const []));
@override
void setCursorFaint() => calls.add(_Call("setCursorFaint", const []));
@override
void setCursorItalic() => calls.add(_Call("setCursorItalic", const []));
@override
void setCursorUnderline() => calls.add(_Call("setCursorUnderline", const []));
@override
void setCursorBlink() => calls.add(_Call("setCursorBlink", const []));
@override
void setCursorInverse() => calls.add(_Call("setCursorInverse", const []));
@override
void setCursorInvisible() => calls.add(_Call("setCursorInvisible", const []));
@override
void setCursorStrikethrough() => calls.add(_Call("setCursorStrikethrough", const []));
@override
void unsetCursorBold() => calls.add(_Call("unsetCursorBold", const []));
@override
void unsetCursorFaint() => calls.add(_Call("unsetCursorFaint", const []));
@override
void unsetCursorItalic() => calls.add(_Call("unsetCursorItalic", const []));
@override
void unsetCursorUnderline() => calls.add(_Call("unsetCursorUnderline", const []));
@override
void unsetCursorBlink() => calls.add(_Call("unsetCursorBlink", const []));
@override
void unsetCursorInverse() => calls.add(_Call("unsetCursorInverse", const []));
@override
void unsetCursorInvisible() => calls.add(_Call("unsetCursorInvisible", const []));
@override
void unsetCursorStrikethrough() => calls.add(_Call("unsetCursorStrikethrough", const []));
@override
void setForegroundColor16(int color) => calls.add(_Call("setForegroundColor16", [color]));
@override
void setForegroundColor256(int index) => calls.add(_Call("setForegroundColor256", [index]));
@override
void setForegroundColorRgb(int r, int g, int b) => calls.add(_Call("setForegroundColorRgb", [r, g, b]));
@override
void resetForeground() => calls.add(_Call("resetForeground", const []));
@override
void setBackgroundColor16(int color) => calls.add(_Call("setBackgroundColor16", [color]));
@override
void setBackgroundColor256(int index) => calls.add(_Call("setBackgroundColor256", [index]));
@override
void setBackgroundColorRgb(int r, int g, int b) => calls.add(_Call("setBackgroundColorRgb", [r, g, b]));
@override
void resetBackground() => calls.add(_Call("resetBackground", const []));
@override
void unsupportedStyle(int param) => calls.add(_Call("unsupportedStyle", [param]));
@override
void setTitle(String name) => calls.add(_Call("setTitle", [name]));
@override
void setIconName(String name) => calls.add(_Call("setIconName", [name]));
@override
void unknownOSC(String code, List<String> args) => calls.add(_Call("unknownOSC", [code, args]));
}
({EscapeParser parser, _RecordingHandler h}) _newParser() {
final h = _RecordingHandler();
return (parser: EscapeParser(h), h: h);
}
void main() {
group('EscapeParser — plain characters', () {
test('writes printable chars one-by-one to writeChar', () {
final f = _newParser();
f.parser.write('Hi!');
expect(f.h.calls.map((c) => c.name).toList(), ['writeChar', 'writeChar', 'writeChar']);
expect(f.h.calls.map((c) => c.args.first).toList(), ['H'.codeUnitAt(0), 'i'.codeUnitAt(0), '!'.codeUnitAt(0)]);
});
test('chars beyond the SBC table go straight to writeChar', () {
// Codepoint above the lookup table maxIndex falls through to the
// explicit writeChar path.
final f = _newParser();
f.parser.write('中');
expect(f.h.calls.single.name, 'writeChar');
expect(f.h.calls.single.args.single, '中'.runes.first);
});
test('low-byte char with no SBC entry calls unkownEscape', () {
// 0x06 is below maxIndex but has no entry — parser routes it to
// unkownEscape (note the typo in handler.dart).
final f = _newParser();
f.parser.write(String.fromCharCode(0x06));
expect(f.h.calls.single.name, 'unkownEscape');
});
});
group('EscapeParser — single-byte controls', () {
test('BEL, BS, HT, LF, VT, FF, CR, SO, SI', () {
final f = _newParser();
f.parser.write('\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f');
expect(f.h.calls.map((c) => c.name).toList(), [
'bell',
'backspaceReturn',
'tab',
'lineFeed', // LF
'lineFeed', // VT shares LF
'lineFeed', // FF shares LF
'carriageReturn',
'shiftOut',
'shiftIn',
]);
});
});
group('EscapeParser — ESC sequences', () {
test('ESC 7 / ESC 8 → save / restore cursor', () {
final f = _newParser();
f.parser.write('\x1b7\x1b8');
expect(f.h.calls.map((c) => c.name).toList(), ['saveCursor', 'restoreCursor']);
});
test('ESC D / E / H / M', () {
final f = _newParser();
f.parser.write('\x1bD\x1bE\x1bH\x1bM');
expect(f.h.calls.map((c) => c.name).toList(), ['index', 'nextLine', 'setTapStop', 'reverseIndex']);
});
test('ESC ( / ESC ) designate G0 / G1 charset', () {
final f = _newParser();
f.parser.write('\x1b(B\x1b)0');
expect(f.h.calls.length, 2);
expect(f.h.calls[0].name, 'designateCharset');
expect(f.h.calls[0].args, [0, 'B'.codeUnitAt(0)]);
expect(f.h.calls[1].name, 'designateCharset');
expect(f.h.calls[1].args, [1, '0'.codeUnitAt(0)]);
});
test('ESC = / ESC > toggle application keypad mode (DECKPAM/DECKPNM)', () {
final f = _newParser();
// ESC = enables app keypad mode, ESC > disables it.
f.parser.write('\x1b=\x1b>');
expect(f.h.named('setAppKeypadMode').map((c) => c.args.first).toList(), [true, false]);
});
test('unknown ESC byte → unkownEscape', () {
final f = _newParser();
f.parser.write('\x1bZ'); // Z has no handler
expect(f.h.calls.single.name, 'unkownEscape');
expect(f.h.calls.single.args.single, 'Z'.codeUnitAt(0));
});
test('ESC alone is held back until the next chunk completes it', () {
final f = _newParser();
f.parser.write('\x1b');
expect(f.h.calls, isEmpty); // nothing dispatched yet
f.parser.write('D'); // now ESC D = index
expect(f.h.calls.single.name, 'index');
});
test('ESC ( without a charset name is held back', () {
final f = _newParser();
f.parser.write('\x1b(');
expect(f.h.calls, isEmpty);
f.parser.write('B');
expect(f.h.calls.single.name, 'designateCharset');
});
});
group('EscapeParser — CSI cursor movement', () {
test('A / B / C / D — default amount = 1, missing param uses default', () {
final f = _newParser();
f.parser.write('\x1b[A\x1b[B\x1b[C\x1b[D');
expect(f.h.calls.map((c) => '${c.name}/${c.args}').toList(), ['moveCursorY/[-1]', 'moveCursorY/[1]', 'moveCursorX/[1]', 'moveCursorX/[-1]']);
});
test('A / B / C / D — explicit amount + 0-as-1 fallback', () {
// A negates (cursor up), B/D pass through, C also passes through.
final f = _newParser();
f.parser.write('\x1b[3A'); // up 3 → moveCursorY(-3)
expect(f.h.lastCallNamed('moveCursorY').args, [-3]);
f.h.clear();
f.parser.write('\x1b[0B'); // 0 falls back to 1 → moveCursorY(1)
expect(f.h.lastCallNamed('moveCursorY').args, [1]);
});
test('E / F — cursor next/preceding line', () {
final f = _newParser();
f.parser.write('\x1b[2E\x1b[F');
expect(f.h.named('cursorNextLine').first.args, [2]);
expect(f.h.named('cursorPrecedingLine').first.args, [1]);
});
test('G — cursor horizontal absolute (1-based to 0-based)', () {
final f = _newParser();
f.parser.write('\x1b[5G');
expect(f.h.lastCallNamed('setCursorX').args, [4]); // 5-1
});
test('H / f — cursor position with both row and column', () {
final f = _newParser();
f.parser.write('\x1b[3;7H\x1b[2;4f');
expect(f.h.named('setCursor').first.args, [6, 2]); // (col-1, row-1)
expect(f.h.named('setCursor').last.args, [3, 1]);
});
test('H — no params resets to (0, 0)', () {
final f = _newParser();
f.parser.write('\x1b[H');
expect(f.h.lastCallNamed('setCursor').args, [0, 0]);
});
test('d — line position absolute (1-based)', () {
final f = _newParser();
f.parser.write('\x1b[3d');
expect(f.h.lastCallNamed('setCursorY').args, [2]);
f.h.clear();
f.parser.write('\x1b[d'); // default = 1 → 0-based 0
expect(f.h.lastCallNamed('setCursorY').args, [0]);
});
});
group('EscapeParser — CSI erase / scroll / lines / chars', () {
test('J 0/1/2/3 → erase display below/above/all/scrollback', () {
final f = _newParser();
f.parser.write('\x1b[J\x1b[1J\x1b[2J\x1b[3J');
expect(f.h.calls.map((c) => c.name).toList(), ['eraseDisplayBelow', 'eraseDisplayAbove', 'eraseDisplay', 'eraseScrollbackOnly']);
});
test('K 0/1/2 → erase line right / left / all', () {
final f = _newParser();
f.parser.write('\x1b[K\x1b[1K\x1b[2K');
expect(f.h.calls.map((c) => c.name).toList(), ['eraseLineRight', 'eraseLineLeft', 'eraseLine']);
});
test('L / M — insert / delete lines', () {
final f = _newParser();
f.parser.write('\x1b[L\x1b[3M');
expect(f.h.named('insertLines').first.args, [1]);
expect(f.h.named('deleteLines').first.args, [3]);
});
test('P — delete chars; @ — insert blanks; X — erase chars', () {
final f = _newParser();
f.parser.write('\x1b[2P\x1b[3@\x1b[4X');
expect(f.h.named('deleteChars').first.args, [2]);
expect(f.h.named('insertBlankChars').first.args, [3]);
expect(f.h.named('eraseChars').first.args, [4]);
});
test('S / T — scroll up / down', () {
final f = _newParser();
f.parser.write('\x1b[2S\x1b[3T');
expect(f.h.named('scrollUp').first.args, [2]);
expect(f.h.named('scrollDown').first.args, [3]);
});
test('b — repeat previous character', () {
final f = _newParser();
f.parser.write('\x1b[3b');
expect(f.h.lastCallNamed('repeatPreviousCharacter').args, [3]);
f.h.clear();
f.parser.write('\x1b[0b'); // 0 falls back to 1
expect(f.h.lastCallNamed('repeatPreviousCharacter').args, [1]);
f.h.clear();
f.parser.write('\x1b[b'); // empty defaults to 1
expect(f.h.lastCallNamed('repeatPreviousCharacter').args, [1]);
});
test('g — clear tab stop (default 0 → under cursor; non-zero → all)', () {
final f = _newParser();
f.parser.write('\x1b[g\x1b[3g');
expect(f.h.calls.map((c) => c.name).toList(), ['clearTabStopUnderCursor', 'clearAllTabStops']);
});
test('r — set top/bottom margins; default top, no bottom', () {
final f = _newParser();
f.parser.write('\x1b[3;10r');
expect(f.h.named('setMargins').last.args, [2, 9]); // (top-1, bottom-1)
f.h.clear();
f.parser.write('\x1b[r'); // no params → top=1, bottom=null
expect(f.h.named('setMargins').last.args, [0, null]);
f.h.clear();
f.parser.write('\x1b[1;2;3r'); // too many params → no-op
expect(f.h.named('setMargins'), isEmpty);
});
});
group('EscapeParser — CSI device attributes / status reports', () {
test('c / >c / =c — primary, secondary, tertiary device attributes', () {
final f = _newParser();
f.parser.write('\x1b[c\x1b[>c\x1b[=c');
expect(f.h.calls.map((c) => c.name).toList(), ['sendPrimaryDeviceAttributes', 'sendSecondaryDeviceAttributes', 'sendTertiaryDeviceAttributes']);
});
test('n 5 → operating status; n 6 → cursor position', () {
final f = _newParser();
f.parser.write('\x1b[5n\x1b[6n');
expect(f.h.calls.map((c) => c.name).toList(), ['sendOperatingStatus', 'sendCursorPosition']);
});
test('n with no params is a no-op (defensive)', () {
final f = _newParser();
f.parser.write('\x1b[n');
expect(f.h.calls, isEmpty);
});
});
group('EscapeParser — CSI window manipulation', () {
test('CSI 8 ; rows ; cols t → resize(cols, rows)', () {
final f = _newParser();
f.parser.write('\x1b[8;24;80t');
expect(f.h.lastCallNamed('resize').args, [80, 24]);
});
test('CSI 18 t → sendSize', () {
final f = _newParser();
f.parser.write('\x1b[18t');
expect(f.h.calls.single.name, 'sendSize');
});
test('CSI ignored window ops (no params, no-scope codes) emit nothing', () {
final f = _newParser();
f.parser.write('\x1b[t\x1b[1t\x1b[5t\x1b[9t\x1b[19t\x1b[22t\x1b[99t');
expect(f.h.calls, isEmpty);
});
test('CSI 8 t with wrong number of params is a no-op', () {
final f = _newParser();
f.parser.write('\x1b[8t');
expect(f.h.calls, isEmpty);
});
});
group('EscapeParser — CSI mode set / reset', () {
test('CSI [4]h / l → setInsertMode true/false', () {
final f = _newParser();
f.parser.write('\x1b[4h\x1b[4l');
expect(f.h.named('setInsertMode').map((c) => c.args.first).toList(), [true, false]);
});
test('CSI [20]h → setLineFeedMode', () {
final f = _newParser();
f.parser.write('\x1b[20h');
expect(f.h.named('setLineFeedMode').first.args, [true]);
});
test('CSI unknown mode → setUnknownMode(mode, enabled)', () {
final f = _newParser();
f.parser.write('\x1b[99h\x1b[99l');
expect(f.h.named('setUnknownMode').map((c) => c.args).toList(), [
[99, true],
[99, false],
]);
});
test('CSI ?1 / ?7 / ?25 / ?2004 — DEC modes route correctly', () {
final f = _newParser();
f.parser.write('\x1b[?1h\x1b[?7l\x1b[?25h\x1b[?2004l');
expect(f.h.named('setCursorKeysMode').first.args, [true]);
expect(f.h.named('setAutoWrapMode').first.args, [false]);
expect(f.h.named('setCursorVisibleMode').first.args, [true]);
expect(f.h.named('setBracketedPasteMode').first.args, [false]);
});
test('CSI ?47 — alt buffer toggle', () {
final f = _newParser();
f.parser.write('\x1b[?47h\x1b[?47l');
expect(f.h.named('useAltBuffer').length, 1);
expect(f.h.named('useMainBuffer').length, 1);
});
test('CSI ?1047 — alt buffer + clear on disable', () {
final f = _newParser();
f.parser.write('\x1b[?1047h\x1b[?1047l');
expect(f.h.named('useAltBuffer').length, 1);
expect(f.h.named('clearAltBuffer').length, 1);
expect(f.h.named('useMainBuffer').length, 1);
});
test('CSI ?1048 — save / restore cursor', () {
final f = _newParser();
f.parser.write('\x1b[?1048h\x1b[?1048l');
expect(f.h.named('saveCursor').length, 1);
expect(f.h.named('restoreCursor').length, 1);
});
test('CSI ?1049 — save+clear+alt on enable; main on disable', () {
final f = _newParser();
f.parser.write('\x1b[?1049h\x1b[?1049l');
expect(f.h.named('saveCursor').length, 1);
expect(f.h.named('clearAltBuffer').length, 1);
expect(f.h.named('useAltBuffer').length, 1);
expect(f.h.named('useMainBuffer').length, 1);
});
test('CSI ?9 / ?1000 / ?1002 / ?1003 — mouse modes route to setMouseMode', () {
final f = _newParser();
f.parser.write('\x1b[?9h\x1b[?1000h\x1b[?1002h\x1b[?1003h');
final modes = f.h.named('setMouseMode').map((c) => c.args.first).toList();
expect(modes, [MouseMode.clickOnly, MouseMode.upDownScroll, MouseMode.upDownScrollDrag, MouseMode.upDownScrollMove]);
});
test('CSI ?9 / ?1000 disabled → setMouseMode(none)', () {
final f = _newParser();
f.parser.write('\x1b[?9l\x1b[?1000l');
final modes = f.h.named('setMouseMode').map((c) => c.args.first).toList();
expect(modes, [MouseMode.none, MouseMode.none]);
});
test('CSI ?1005 / ?1006 / ?1015 — mouse report modes', () {
final f = _newParser();
f.parser.write('\x1b[?1005h\x1b[?1006h\x1b[?1015h\x1b[?1006l');
final modes = f.h.named('setMouseReportMode').map((c) => c.args.first).toList();
expect(modes, [MouseReportMode.utf, MouseReportMode.sgr, MouseReportMode.urxvt, MouseReportMode.normal]);
});
test('CSI ?3 / ?5 / ?6 / ?12 / ?66 / ?1004 / ?1007 — column/reverse/origin/blink/keypad/focus/altScroll', () {
final f = _newParser();
f.parser.write('\x1b[?3h\x1b[?5l\x1b[?6h\x1b[?12h\x1b[?66h\x1b[?1004h\x1b[?1007h');
expect(f.h.named('setColumnMode').first.args, [true]);
expect(f.h.named('setReverseDisplayMode').first.args, [false]);
expect(f.h.named('setOriginMode').first.args, [true]);
expect(f.h.named('setCursorBlinkMode').first.args, [true]);
expect(f.h.named('setAppKeypadMode').first.args, [true]);
expect(f.h.named('setReportFocusMode').first.args, [true]);
expect(f.h.named('setAltBufferMouseScrollMode').first.args, [true]);
});
test('CSI ?9999 — unknown DEC mode falls through to setUnknownDecMode', () {
final f = _newParser();
f.parser.write('\x1b[?9999h');
expect(f.h.named('setUnknownDecMode').first.args, [9999, true]);
});
});
group('EscapeParser — CSI SGR styling', () {
test('empty params → resetCursorStyle', () {
final f = _newParser();
f.parser.write('\x1b[m');
expect(f.h.calls.single.name, 'resetCursorStyle');
});
test('single attribute params 0..9 / 21..29 — set + unset', () {
final f = _newParser();
f.parser.write('\x1b[0;1;2;3;4;5;7;8;9m');
expect(f.h.calls.map((c) => c.name).toList(), [
'resetCursorStyle',
'setCursorBold',
'setCursorFaint',
'setCursorItalic',
'setCursorUnderline',
'setCursorBlink',
'setCursorInverse',
'setCursorInvisible',
'setCursorStrikethrough',
]);
f.h.clear();
f.parser.write('\x1b[21;22;23;24;25;27;28;29m');
expect(f.h.calls.map((c) => c.name).toList(), [
'unsetCursorBold',
'unsetCursorFaint',
'unsetCursorItalic',
'unsetCursorUnderline',
'unsetCursorBlink',
'unsetCursorInverse',
'unsetCursorInvisible',
'unsetCursorStrikethrough',
]);
});
test('foreground colour 16 — params 30..37 + 90..97', () {
final f = _newParser();
f.parser.write('\x1b[30;31;32;33;34;35;36;37;90;91;92;93;94;95;96;97m');
// 16 calls to setForegroundColor16 with successive NamedColor int values.
final args = f.h.named('setForegroundColor16').map((c) => c.args.first).toList();
// NamedColor.black==0 .. white==7, brightBlack==8 .. brightWhite==15.
expect(args, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
});
test('background colour 16 — params 40..47 + 100..107', () {
final f = _newParser();
f.parser.write('\x1b[40;41;42;43;44;45;46;47;100;101;102;103;104;105;106;107m');
final args = f.h.named('setBackgroundColor16').map((c) => c.args.first).toList();
expect(args, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
});
test('38;5;n / 48;5;n — 256-colour palette', () {
final f = _newParser();
f.parser.write('\x1b[38;5;200m\x1b[48;5;42m');
expect(f.h.named('setForegroundColor256').first.args, [200]);
expect(f.h.named('setBackgroundColor256').first.args, [42]);
});
test('38;2;r;g;b / 48;2;r;g;b — 24-bit RGB', () {
final f = _newParser();
f.parser.write('\x1b[38;2;10;20;30m\x1b[48;2;100;150;200m');
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
expect(f.h.named('setBackgroundColorRgb').first.args, [100, 150, 200]);
});
test('39 / 49 — reset foreground / background', () {
final f = _newParser();
f.parser.write('\x1b[39;49m');
expect(f.h.named('resetForeground').length, 1);
expect(f.h.named('resetBackground').length, 1);
});
test('unknown SGR param → unsupportedStyle', () {
final f = _newParser();
f.parser.write('\x1b[123m');
expect(f.h.named('unsupportedStyle').first.args, [123]);
});
// T-369: an emulator must never throw on hostile bytes. The old code did
// unguarded params[i+1] lookahead in 38/48 — `\x1b[38m` was a RangeError
// inside Terminal.write.
test('truncated 38/48 sequences are ignored, never throw', () {
final f = _newParser();
for (final s in [
'\x1b[38m',
'\x1b[48m',
'\x1b[38;2m',
'\x1b[38;2;255m',
'\x1b[38;2;255;10m',
'\x1b[38;5m',
'\x1b[48;5m',
'\x1b[38:2m',
'\x1b[38:5m',
'\x1b[48:2:255m',
]) {
f.parser.write(s);
}
expect(f.h.named('setForegroundColorRgb'), isEmpty);
expect(f.h.named('setBackgroundColorRgb'), isEmpty);
expect(f.h.named('setForegroundColor256'), isEmpty);
expect(f.h.named('setBackgroundColor256'), isEmpty);
});
test('colon-form truecolor matches semicolon form (ITU T.416)', () {
final f = _newParser();
f.parser.write('\x1b[38:2:10:20:30m\x1b[48:2:100:150:200m');
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
expect(f.h.named('setBackgroundColorRgb').first.args, [100, 150, 200]);
});
test('colon-form with empty colorspace slot — 38:2::r:g:b', () {
final f = _newParser();
f.parser.write('\x1b[38:2::10:20:30m');
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
});
test('colon-form 256-colour — 38:5:n', () {
final f = _newParser();
f.parser.write('\x1b[38:5:200m\x1b[48:5:42m');
expect(f.h.named('setForegroundColor256').first.args, [200]);
expect(f.h.named('setBackgroundColor256').first.args, [42]);
});
test('a malformed colon group is dropped whole, neighbours still apply', () {
final f = _newParser();
// The bogus 38:2:255 group must not bleed into the following bold.
f.parser.write('\x1b[38:2:255;1m');
expect(f.h.named('setForegroundColorRgb'), isEmpty);
expect(f.h.named('setCursorBold').length, 1);
});
test('extended color followed by more SGR params keeps positions', () {
final f = _newParser();
f.parser.write('\x1b[1;38;2;10;20;30;4m');
expect(f.h.named('setCursorBold').length, 1);
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
expect(f.h.named('setCursorUnderline').length, 1);
});
});
group('EscapeParser — OSC sequences', () {
test('OSC 0 ; title BEL → setTitle + setIconName', () {
final f = _newParser();
f.parser.write('\x1b]0;hello\x07');
expect(f.h.named('setTitle').first.args, ['hello']);
expect(f.h.named('setIconName').first.args, ['hello']);
});
test('OSC 1 ; iconname BEL → setIconName only', () {
final f = _newParser();
f.parser.write('\x1b]1;icon\x07');
expect(f.h.named('setIconName').first.args, ['icon']);
expect(f.h.named('setTitle'), isEmpty);
});
test('OSC 2 ; title BEL → setTitle only', () {
final f = _newParser();
f.parser.write('\x1b]2;title\x07');
expect(f.h.named('setTitle').first.args, ['title']);
expect(f.h.named('setIconName'), isEmpty);
});
test('OSC terminated by ST (ESC \\) instead of BEL', () {
final f = _newParser();
f.parser.write('\x1b]0;via-st\x1b\\');
expect(f.h.named('setTitle').first.args, ['via-st']);
});
test('OSC unknown ps → unknownOSC', () {
final f = _newParser();
f.parser.write('\x1b]99;arg1;arg2\x07');
expect(f.h.named('unknownOSC').first.args[0], '99');
expect(f.h.named('unknownOSC').first.args[1], ['arg1', 'arg2']);
});
test('OSC with only a terminator dispatches unknownOSC with empty params', () {
// _consumeOsc records the empty string before BEL, so _osc = [""];
// length is 1 (not 0), so the empty-fast-path doesn't fire and the
// sequence falls through to unknownOSC.
final f = _newParser();
f.parser.write('\x1b]\x07');
expect(f.h.named('unknownOSC').first.args[0], '');
expect(f.h.named('unknownOSC').first.args[1], <String>[]);
});
test('OSC incomplete sequence is held back until terminator arrives', () {
final f = _newParser();
f.parser.write('\x1b]0;par');
expect(f.h.calls, isEmpty);
f.parser.write('tial\x07');
expect(f.h.named('setTitle').first.args, ['partial']);
});
});
group('EscapeParser — CSI unknown final byte', () {
test('routes to unknownCSI(finalByte)', () {
final f = _newParser();
f.parser.write('\x1b[~'); // ~ has no entry in _csiHandlers
expect(f.h.named('unknownCSI').first.args, ['~'.codeUnitAt(0)]);
});
});
group('EscapeParser — CSI intermediate bytes (T-123)', () {
test('CSI 5 SP @ (SL) does not mis-dispatch as insert-blank-chars', () {
final f = _newParser();
f.parser.write('\x1b[5 @');
expect(f.h.named('insertBlankChars'), isEmpty);
expect(f.h.named('unknownCSI').first.args, ['@'.codeUnitAt(0)]);
});
test('CSI 4 SP q (DECSCUSR) routes to unknownCSI, not a bare-q handler', () {
final f = _newParser();
f.parser.write('\x1b[4 q');
expect(f.h.named('unknownCSI').first.args, ['q'.codeUnitAt(0)]);
});
test('CSI ! p (DECSTR) routes to unknownCSI', () {
final f = _newParser();
f.parser.write('\x1b[!p');
expect(f.h.named('unknownCSI').first.args, ['p'.codeUnitAt(0)]);
});
test('intermediates reset between sequences', () {
final f = _newParser();
f.parser.write('\x1b[5 @'); // intermediate form → unknownCSI
f.parser.write('\x1b[3@'); // plain form must dispatch normally again
expect(f.h.named('insertBlankChars').first.args, [3]);
});
});
group('EscapeParser — token bookkeeping', () {
test('tokenBegin / tokenEnd advance with consumed bytes', () {
final f = _newParser();
f.parser.write('AB\x1b[H');
// Last token was the CSI H sequence. tokenBegin sits at the start
// of the ESC; tokenEnd advances to one past the last consumed byte.
expect(f.parser.tokenEnd, greaterThan(f.parser.tokenBegin));
expect(f.parser.tokenEnd, 5); // total bytes consumed
});
});
group('EscapeEmitter', () {
const e = EscapeEmitter();
test('primary device attributes', () {
expect(e.primaryDeviceAttributes(), '\x1b[?1;2c');
});
test('secondary device attributes', () {
expect(e.secondaryDeviceAttributes(), '\x1b[>0;0;0c');
});
test('tertiary device attributes', () {
expect(e.tertiaryDeviceAttributes(), '\x1bP!|00000000\x1b\\');
});
test('operating status', () {
expect(e.operatingStatus(), '\x1b[0n');
});
test('cursor position', () {
expect(e.cursorPosition(7, 3), '\x1b[3;7R');
});
test('bracketed paste wraps the text', () {
expect(e.bracketedPaste('abc'), '\x1b[200~abc\x1b[201~');
});
test('size emits CSI 8 ; rows ; cols t', () {
expect(e.size(24, 80), '\x1b[8;24;80t');
});
});
}