Files
clide/test/terminal/core/core_test.dart
T
jpmschweitzerandClaude Opus 4.8 6d0ebab721 chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)
Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:11:53 +02:00

423 lines
16 KiB
Dart

/// Pure-Dart tests for the small `lib/src/terminal/src/core/*.dart`
/// files — cell, cursor, charset, tabs, reflow.
library;
import 'package:clide/src/terminal/src/core/buffer/line.dart';
import 'package:clide/src/terminal/src/core/cell.dart';
import 'package:clide/src/terminal/src/core/charset.dart';
import 'package:clide/src/terminal/src/core/cursor.dart';
import 'package:clide/src/terminal/src/core/reflow.dart';
import 'package:clide/src/terminal/src/core/tabs.dart';
import 'package:clide/src/terminal/src/utils/circular_buffer.dart';
import 'package:test/test.dart';
void main() {
group('CellData', () {
test('explicit constructor stores all four channels', () {
final c = CellData(foreground: 1, background: 2, flags: 4, content: 8);
expect(c.foreground, 1);
expect(c.background, 2);
expect(c.flags, 4);
expect(c.content, 8);
});
test('CellData.empty factory zeroes everything', () {
final c = CellData.empty();
expect(c.foreground, 0);
expect(c.background, 0);
expect(c.flags, 0);
expect(c.content, 0);
});
test('getHash combines all four channels (different inputs → different hash)', () {
final a = CellData(foreground: 1, background: 2, flags: 3, content: 4);
final b = CellData(foreground: 1, background: 2, flags: 3, content: 4);
expect(a.getHash(), b.getHash());
final c = CellData(foreground: 1, background: 2, flags: 3, content: 5);
expect(a.getHash(), isNot(c.getHash()));
});
test('toString shape is the documented debug format', () {
final c = CellData(foreground: 1, background: 2, flags: 4, content: 8);
expect(c.toString(), 'CellData{foreground: 1, background: 2, flags: 4, content: 8}');
});
});
group('CursorStyle', () {
test('default constructor zeroes all three fields', () {
final c = CursorStyle();
expect(c.foreground, 0);
expect(c.background, 0);
expect(c.attrs, 0);
});
test('every set / unset attr toggles the right CellAttr bit', () {
final c = CursorStyle();
// Pair each setter with its corresponding getter and unsetter.
final cases = <(void Function(), void Function(), bool Function())>[
(c.setBold, c.unsetBold, () => c.isBold),
(c.setFaint, c.unsetFaint, () => c.isFaint),
(c.setItalic, c.unsetItalic, () => c.isItalic),
(c.setUnderline, c.unsetUnderline, () => c.isUnderline),
(c.setBlink, c.unsetBlink, () => c.isBlink),
(c.setInverse, c.unsetInverse, () => c.isInverse),
(c.setInvisible, c.unsetInvisible, () => c.isInvisible),
];
for (final (set, unset, getter) in cases) {
c.attrs = 0;
expect(getter(), isFalse);
set();
expect(getter(), isTrue);
unset();
expect(getter(), isFalse);
}
// Strikethrough has no getter — exercise its set / unset directly.
c.attrs = 0;
c.setStrikethrough();
expect(c.attrs & CellAttr.strikethrough, isNot(0));
c.unsetStrikethrough();
expect(c.attrs & CellAttr.strikethrough, 0);
});
test('foreground 16 / 256 / RGB encode the right CellColor type bits', () {
final c = CursorStyle();
c.setForegroundColor16(3);
expect(c.foreground, 3 | CellColor.named);
c.setForegroundColor256(200);
expect(c.foreground, 200 | CellColor.palette);
c.setForegroundColorRgb(0x10, 0x20, 0x30);
expect(c.foreground, 0x10 << 16 | 0x20 << 8 | 0x30 | CellColor.rgb);
c.resetForegroundColor();
expect(c.foreground, 0);
});
test('background 16 / 256 / RGB encode the right CellColor type bits', () {
final c = CursorStyle();
c.setBackgroundColor16(5);
expect(c.background, 5 | CellColor.named);
c.setBackgroundColor256(123);
expect(c.background, 123 | CellColor.palette);
c.setBackgroundColorRgb(1, 2, 3);
expect(c.background, 1 << 16 | 2 << 8 | 3 | CellColor.rgb);
c.resetBackgroundColor();
expect(c.background, 0);
});
test('reset wipes all three fields', () {
final c = CursorStyle()
..setBold()
..setForegroundColor16(2)
..setBackgroundColor256(99);
c.reset();
expect(c.foreground, 0);
expect(c.background, 0);
expect(c.attrs, 0);
});
test('CursorStyle.empty exposes a default-zero singleton', () {
// Don't mutate this — it's a shared singleton.
expect(CursorStyle.empty.foreground, 0);
expect(CursorStyle.empty.background, 0);
expect(CursorStyle.empty.attrs, 0);
});
});
group('CursorPosition', () {
test('exposes mutable x / y fields', () {
final p = CursorPosition(3, 5);
expect(p.x, 3);
expect(p.y, 5);
p.x = 7;
p.y = 9;
expect(p.x, 7);
expect(p.y, 9);
});
});
group('Charset', () {
test('asciiTranslator is identity', () {
expect(asciiTranslator(0x41), 0x41);
expect(asciiTranslator(0x7e), 0x7e);
});
test('decSpecGraphicsTranslator maps the documented codepoints', () {
// 0x6a → BOX DRAWINGS LIGHT UP AND LEFT (0x2518)
expect(decSpecGraphicsTranslator(0x6a), 0x2518);
// 0x71 → BOX DRAWINGS LIGHT HORIZONTAL (0x2500)
expect(decSpecGraphicsTranslator(0x71), 0x2500);
});
test('decSpecGraphicsTranslator passes unmapped low-codepoints through', () {
expect(decSpecGraphicsTranslator(0x41), 0x41); // 'A' is unmapped
});
test('decSpecGraphicsTranslator passes high codepoints through', () {
// The spec only maps 0x5f..0x7e; anything ≥ 127 is identity.
expect(decSpecGraphicsTranslator(0x100), 0x100);
expect(decSpecGraphicsTranslator(0x4E2D), 0x4E2D);
});
test('translate defaults to ascii when no charset is designated', () {
final c = Charset();
expect(c.translate(0x6a), 0x6a);
});
test('designate + use switch the active translator', () {
final c = Charset();
// Designate the DEC special-graphics charset at slot 0…
c.designate(0, '0'.codeUnitAt(0));
// …and select slot 0 as the active charset.
c.use(0);
expect(c.translate(0x6a), 0x2518); // now maps via DEC graphics
// Switch back to a slot with no designated charset → ascii fallback.
c.use(1);
expect(c.translate(0x6a), 0x6a);
});
test('designate ignores unknown charset names without changing state', () {
final c = Charset();
c.designate(0, 0xFFFF); // not in the _charsets table
c.use(0);
expect(c.translate(0x6a), 0x6a); // still ascii
});
test('save / restore round-trips both the map and the active index', () {
final c = Charset();
c.designate(0, '0'.codeUnitAt(0));
c.use(0);
c.save();
// Mutate after save…
c.designate(0, 'B'.codeUnitAt(0)); // ascii in slot 0 — translate is identity
c.use(1);
expect(c.translate(0x6a), 0x6a);
// …restore should reinstate the saved DEC-graphics binding.
c.restore();
expect(c.translate(0x6a), 0x2518);
});
});
group('TabStops', () {
test('default state has tab stops every 8 columns', () {
final t = TabStops();
for (var i = 0; i < 100; i++) {
expect(t.isSetAt(i), i % 8 == 0, reason: 'col $i');
}
});
test('find — returns first set stop in [start, end)', () {
final t = TabStops();
// Default: 0, 8, 16, 24, ...
expect(t.find(1, 20), 8);
expect(t.find(8, 20), 8);
expect(t.find(9, 16), isNull); // no stops in [9, 16)
expect(t.find(0, 1), 0);
});
test('find returns null when start >= end', () {
final t = TabStops();
expect(t.find(10, 10), isNull);
expect(t.find(20, 5), isNull);
});
test('find clamps end to the underlying array length', () {
final t = TabStops();
// 99999 > _kMaxColumns; should still return a valid stop without
// walking out of bounds.
expect(t.find(0, 99999), 0);
});
test('setAt + clearAt toggle individual columns', () {
final t = TabStops();
t.clearAt(8);
expect(t.isSetAt(8), isFalse);
t.setAt(3);
expect(t.isSetAt(3), isTrue);
t.clearAt(3);
expect(t.isSetAt(3), isFalse);
});
test('clearAll wipes every stop without restoring the default grid', () {
final t = TabStops();
t.clearAll();
for (var i = 0; i < 100; i++) {
expect(t.isSetAt(i), isFalse, reason: 'col $i');
}
});
test('reset clears then re-installs the default 8-column grid', () {
final t = TabStops();
t.setAt(3);
t.clearAt(8);
t.reset();
// 3 should no longer be set; 8 should be set again.
expect(t.isSetAt(3), isFalse);
expect(t.isSetAt(8), isTrue);
});
});
group('reflow', () {
BufferLine line(int width, [String? text]) {
final l = BufferLine(width);
if (text != null) {
for (var i = 0; i < text.length; i++) {
l.setCodePoint(i, text.codeUnitAt(i));
}
}
return l;
}
IndexAwareCircularBuffer<BufferLine> ring(List<BufferLine> ls) {
final r = IndexAwareCircularBuffer<BufferLine>(ls.length + 4);
for (final l in ls) {
r.push(l);
}
return r;
}
test('empty input produces empty output', () {
final out = reflow(ring([]), 80, 80);
expect(out, isEmpty);
});
test('single empty line is reused as-is', () {
final l = line(8);
final out = reflow(ring([l]), 8, 8);
expect(out.length, 1);
expect(identical(out.first, l), isTrue);
});
test('grow width keeps lines and resizes them to the new width', () {
final out = reflow(ring([line(4, 'abcd')]), 4, 8);
expect(out, hasLength(1));
expect(out.first.length, 8);
expect(out.first.getCodePoint(0), 'a'.codeUnitAt(0));
expect(out.first.getCodePoint(3), 'd'.codeUnitAt(0));
});
test('shrink width splits a single full line into wrapped pieces', () {
// 8 chars on a width-8 line, reflowed to width 4 → two width-4 lines.
final out = reflow(ring([line(8, 'abcdefgh')]), 8, 4);
expect(out, hasLength(2));
expect(out[0].getCodePoint(0), 'a'.codeUnitAt(0));
expect(out[0].isWrapped, isFalse); // first line of a logical run
expect(out[1].getCodePoint(0), 'e'.codeUnitAt(0));
expect(out[1].isWrapped, isTrue);
});
test('continues a wrapped run across input lines and re-emits wrapped output', () {
// Two width-4 lines forming a single logical line "abcdefgh", reflowed
// back to width 8 → a single line of 8 chars.
final a = line(4, 'abcd');
final b = line(4, 'efgh')..isWrapped = true;
final out = reflow(ring([a, b]), 4, 8);
expect(out, hasLength(1));
expect(out.first.getCodePoint(0), 'a'.codeUnitAt(0));
expect(out.first.getCodePoint(7), 'h'.codeUnitAt(0));
});
test('shrink across a wide char does not split it across two output lines', () {
// Layout on a width-6 input: 'ab中cd' — the wide char straddles
// columns 2..3. Reflow to width 4 → first line is 'ab' + space (the
// wide char wouldn't fit), second line carries the wide char + 'cd'.
final l = BufferLine(6);
l.setCodePoint(0, 'a'.codeUnitAt(0));
l.setCodePoint(1, 'b'.codeUnitAt(0));
l.setCodePoint(2, '中'.runes.first); // width 2 → occupies 2..3
l.setCodePoint(4, 'c'.codeUnitAt(0));
l.setCodePoint(5, 'd'.codeUnitAt(0));
final out = reflow(ring([l]), 6, 4);
expect(out.length, greaterThanOrEqualTo(2));
// First output line should not have started its last cell on the wide
// char's first half — i.e. cell index 3 should be empty (width-clamp
// branch fired during the initial fill).
expect(out[0].getCodePoint(3), 0);
});
test('shrink with a wide char at the new boundary takes the from=newWidth-1 path', () {
// The branch at reflow.dart:88 only fires when the cell at the new
// boundary (newWidth-1) is itself the first half of a wide char.
final l = BufferLine(6);
l.setCodePoint(0, 'a'.codeUnitAt(0));
l.setCodePoint(1, 'b'.codeUnitAt(0));
l.setCodePoint(2, 'c'.codeUnitAt(0));
l.setCodePoint(3, '中'.runes.first); // wide → straddles 3..4
l.setCodePoint(5, 'd'.codeUnitAt(0));
// newWidth=4 → boundary cell is 3, which is the wide char's first half.
final out = reflow(ring([l]), 6, 4);
expect(out.length, greaterThanOrEqualTo(2));
// The wide char should not survive as a half-cell on the first line.
expect(out[0].getCodePoint(3), 0);
});
test('anchors on the source line tail (past trimmedLength) land on a line in the result (T-92)', () {
// The post-loop tail-anchor branch in `_addPart` reparents the
// anchor onto the active builder line. The fix in T-92 makes
// `finish()` emit that builder line when it has anchors, even if
// it's otherwise empty — so the anchor's `line` is guaranteed to
// be in the reflow result and stays attached after replaceWith.
final src = line(12, 'abcdefgh'); // width 12, content 8
final tail = src.createAnchor(10); // past trimmedLength (8)
final out = reflow(ring([src]), 12, 4);
expect(tail.line, isNotNull);
expect(out.contains(tail.line), isTrue);
});
test('SelectAllTextIntent-shaped end anchor survives shrink (T-92 regression)', () {
// Models the actual bug path: SelectAllTextIntent (actions.dart)
// creates an end anchor at x = viewWidth on the last buffer line.
// Resizing narrower must not silently drop the selection.
final src = line(12, 'short');
final endAnchor = src.createAnchor(12); // viewWidth, past trimmedLength
final out = reflow(ring([src]), 12, 4);
expect(
out.contains(endAnchor.line),
isTrue,
reason:
'end anchor must land on a line that survives '
'lines.replaceWith — otherwise selection.attached is false '
'and the selection silently vanishes after resize.',
);
});
test('inner wide-char clamp during _addPart leaves the wide cell to the next iteration', () {
// The clamp at reflow.dart:124 fires when the iteration is about to
// fill the builder AND the last cell would be the first half of a
// wide char — _addPart pushes that copy to the next iteration.
final src = BufferLine(9);
for (var i = 0; i < 7; i++) {
src.setCodePoint(i, 'a'.codeUnitAt(0) + i);
}
// Set wide at index 7 (straddles 7..8).
src.setCodePoint(7, '中'.runes.first);
final out = reflow(ring([src]), 9, 4);
expect(out.length, greaterThanOrEqualTo(2));
});
test('anchors on the source line get reparented to the reflowed output', () {
// CellAnchor.attached delegates to its owning BufferLine.attached
// (which itself reflects whether the line sits in an
// IndexAwareCircularBuffer). Reflow returns a plain List, so the
// output lines aren't "attached" — but anchors should still point
// to one of the lines in the result.
final src = line(8, 'abcdefgh');
final anchorAtStart = src.createAnchor(0);
final anchorMid = src.createAnchor(5);
final out = reflow(ring([src]), 8, 4);
expect(out, hasLength(2));
// anchorAtStart was at x=0 on the original line; reflow's first
// step is `_lines.add(line)`, so the original line is the first
// output. The anchor should still point to that same instance.
expect(out.contains(anchorAtStart.line), isTrue);
// anchorMid was at x=5; the [4..8) tail is split out via _addPart
// and lands on the second output line, with the anchor reparented
// there.
expect(out.contains(anchorMid.line), isTrue);
expect(anchorMid.line, isNot(equals(anchorAtStart.line)));
});
});
}