Files
clide/test/terminal/buffer/buffer_test.dart
T
jpmschweitzerandClaude c233b7607d test sweep: cover Buffer (T-91)
Adds test/terminal/buffer/buffer_test.dart — 67 unit tests against
the Buffer orchestrator on top of BufferLine. Drives a fake
TerminalState through writes, cursor moves, scroll regions,
erase commands, line insert/delete, resize (with and without
reflow), word-boundary lookup, getText, and the toString debug
dump.

Coverage delta:
- buffer.dart: 0 / 260 → 260 / 261 (one while-loop-body line
  Dart coverage doesn't instrument distinctly; the loop's effect
  is exercised end-to-end by the reflow-pad test).
- Total project: 39.12% → 43.20%.
- pubspec.yaml `coverage_floor:` bumped 39 → 43.

Notes:
- The fake TerminalState (`_State`) is a per-file impl rather than
  a shared fixture; it stays close to the test that exercises it
  and avoids forcing other terminal tests to depend on a one-shape-
  fits-all stub.
- Tests that walk through `lineFeed` use `lineFeedMode: true` so
  the column resets between newlines — otherwise the saturated
  cursor X from a previous full-width write spills the next write
  onto an extra line via `writeChar`'s autoWrap branch.

This closes the `core/buffer/` sub-area for T-91 — every leaf file
in `lib/src/terminal/src/core/buffer/` is now at >= 96% line
coverage; the only outliers are Dart-coverage-instrumentation
quirks, not real gaps.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:13:42 +02:00

803 lines
27 KiB
Dart

/// Pure-Dart tests for Buffer (lib/src/terminal/src/core/buffer/buffer.dart).
///
/// Buffer is the orchestrator on top of BufferLine — viewport sizing,
/// scroll regions, cursor movement, erase commands, scroll/index/reverse-
/// index, line insert/delete, resize + reflow, and word-boundary lookup.
library;
import 'package:clide/src/terminal/src/core/buffer/buffer.dart';
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
import 'package:clide/src/terminal/src/core/buffer/range_line.dart';
import 'package:clide/src/terminal/src/core/cursor.dart';
import 'package:clide/src/terminal/src/core/mouse/mode.dart';
import 'package:clide/src/terminal/src/core/state.dart';
import 'package:test/test.dart';
class _State implements TerminalState {
_State({
this.viewWidth = 10,
this.viewHeight = 5,
this.autoWrapMode = true,
this.lineFeedMode = false,
this.originMode = false,
this.reflowEnabled = false,
});
@override
int viewWidth;
@override
int viewHeight;
@override
bool autoWrapMode;
@override
bool lineFeedMode;
@override
bool originMode;
@override
bool reflowEnabled;
@override
final cursor = CursorStyle();
// Unused-by-Buffer bits — dummy values are fine.
@override
bool get insertMode => false;
@override
bool get cursorKeysMode => false;
@override
bool get reverseDisplayMode => false;
@override
MouseMode get mouseMode => MouseMode.none;
@override
MouseReportMode get mouseReportMode => MouseReportMode.normal;
@override
bool get cursorBlinkMode => true;
@override
bool get cursorVisibleMode => true;
@override
bool get appKeypadMode => false;
@override
bool get reportFocusMode => false;
@override
bool get altBufferMouseScrollMode => false;
@override
bool get bracketedPasteMode => false;
}
Buffer _newBuffer({
int viewWidth = 10,
int viewHeight = 5,
int maxLines = 50,
bool isAltBuffer = false,
bool autoWrapMode = true,
bool lineFeedMode = false,
bool originMode = false,
bool reflowEnabled = false,
Set<int>? wordSeparators,
}) {
final state = _State(
viewWidth: viewWidth,
viewHeight: viewHeight,
autoWrapMode: autoWrapMode,
lineFeedMode: lineFeedMode,
originMode: originMode,
reflowEnabled: reflowEnabled,
);
return Buffer(
state,
maxLines: maxLines,
isAltBuffer: isAltBuffer,
wordSeparators: wordSeparators,
);
}
void main() {
group('Buffer — construction', () {
test('seeds viewHeight empty lines and resets margins', () {
final b = _newBuffer(viewWidth: 8, viewHeight: 4);
expect(b.height, 4);
expect(b.viewWidth, 8);
expect(b.viewHeight, 4);
expect(b.cursorX, 0);
expect(b.cursorY, 0);
expect(b.scrollBack, 0);
expect(b.marginTop, 0);
expect(b.marginBottom, 3);
});
test('absolute cursor + margin getters are scrollback-shifted', () {
final b = _newBuffer(viewHeight: 3);
// Push a line via index() in the no-scrollable-region path on a
// primary buffer — that uses lines.push to grow scrollback.
b.setCursor(0, 2);
b.index();
// Now scrollBack == 1; the cursor stayed at viewHeight - 1 = 2.
expect(b.scrollBack, 1);
expect(b.absoluteCursorY, 3);
expect(b.absoluteMarginTop, 1);
expect(b.absoluteMarginBottom, 3);
});
});
group('Buffer — cursor movement and clamping', () {
test('setCursorX/Y clamp to viewWidth-1 / viewHeight-1', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 3);
b.setCursorX(99);
b.setCursorY(99);
expect(b.cursorX, 4);
expect(b.cursorY, 2);
b.setCursorX(-1);
b.setCursorY(-1);
expect(b.cursorX, 0);
expect(b.cursorY, 0);
});
test('moveCursorX / moveCursorY are relative + clamped', () {
final b = _newBuffer();
b.setCursor(5, 2);
b.moveCursorX(2);
b.moveCursorY(1);
expect(b.cursorX, 7);
expect(b.cursorY, 3);
b.moveCursorX(99); // clamps
expect(b.cursorX, 9);
});
test('cursorGoForward saturates at viewWidth (one past last visible)', () {
final b = _newBuffer(viewWidth: 5);
for (var i = 0; i < 10; i++) {
b.cursorGoForward();
}
// The clamped getter caps at viewWidth - 1; the internal saturation
// sits at viewWidth.
expect(b.cursorX, 4);
});
test('setCursor — originMode shifts y by marginTop and clamps to marginBottom', () {
final b = _newBuffer(viewHeight: 6, originMode: true);
b.setVerticalMargins(2, 4);
b.setCursor(0, 0);
// origin shift: cursorY += marginTop (2) → clamped to marginBottom (4).
expect(b.cursorY, 2);
b.setCursor(0, 99);
expect(b.cursorY, 4); // clamped to marginBottom
});
test('moveCursor delegates to setCursor', () {
final b = _newBuffer();
b.setCursor(0, 0);
b.moveCursor(2, 1);
expect(b.cursorX, 2);
expect(b.cursorY, 1);
});
});
group('Buffer — vertical margins', () {
test('setVerticalMargins clamps and orders top/bottom', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(99, -5);
// Both clamp to [0, viewHeight-1] = [0, 4]; the implementation
// also coerces top<=bottom by min/max swap.
expect(b.marginTop, lessThanOrEqualTo(b.marginBottom));
});
test('resetVerticalMargins restores full viewport range', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(1, 3);
b.resetVerticalMargins();
expect(b.marginTop, 0);
expect(b.marginBottom, 4);
});
test('isInVerticalMargin is true inside, false outside', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(1, 3);
b.setCursorY(0);
expect(b.isInVerticalMargin, isFalse);
b.setCursorY(2);
expect(b.isInVerticalMargin, isTrue);
b.setCursorY(4);
expect(b.isInVerticalMargin, isFalse);
});
});
group('Buffer — write / writeChar / autoWrap', () {
test('write adds chars left-to-right and advances the cursor', () {
final b = _newBuffer(viewWidth: 5);
b.write('abc');
expect(b.cursorX, 3);
expect(b.currentLine.getCodePoint(0), 'a'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(1), 'b'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(2), 'c'.codeUnitAt(0));
});
test('autoWrap pushes the cursor to the next line and marks it wrapped', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 4);
b.write('abcdef'); // 'def' wraps to line 1
expect(b.height, 4); // still within view; no scrollback needed yet
expect(b.cursorY, 1);
// cursorX getter clamps to viewWidth-1; internal _cursorX is saturated.
expect(b.cursorX, 2);
expect(b.currentLine.isWrapped, isTrue);
});
test('autoWrap=false leaves the cursor saturated past the end (no wrap)', () {
final b = _newBuffer(viewWidth: 3, autoWrapMode: false);
b.write('abc');
// After three writes the cursor is at viewWidth (3). The next write
// triggers index() but autoWrapMode is off — the line shouldn't be
// marked wrapped.
b.write('d');
expect(b.currentLine.isWrapped, isFalse);
});
test('wide character writes a width-2 cell + a 0-codepoint trailer', () {
final b = _newBuffer(viewWidth: 5);
b.write('中'); // CJK ideograph, width 2
expect(b.cursorX, 2);
});
});
group('Buffer — backspace', () {
test('column 0 on a wrapped line jumps to the prior line end and unwraps', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 3);
b.write('abcd'); // wraps; cursor at (1, 1) on a wrapped line
expect(b.currentLine.isWrapped, isTrue);
b.setCursor(0, 1);
// Re-flag wrapped (setCursor may not affect it but to be sure)
b.currentLine.isWrapped = true;
b.backspace();
expect(b.cursorX, 2); // viewWidth - 1
expect(b.cursorY, 0);
});
test('column == viewWidth backspaces by 2 cells (off the saturated edge)', () {
final b = _newBuffer(viewWidth: 5);
// Saturate the cursor.
for (var i = 0; i < 5; i++) {
b.cursorGoForward();
}
// Internal _cursorX is now 5 (viewWidth). The clamped getter shows 4.
b.backspace();
// -2 of (5) clamps to (3). cursorX getter returns clamped value.
expect(b.cursorX, 3);
});
test('mid-line backspace is just a -1', () {
final b = _newBuffer(viewWidth: 5);
b.setCursor(3, 0);
b.backspace();
expect(b.cursorX, 2);
});
});
group('Buffer — erase commands', () {
test('eraseLine clears the whole current line and clears wrapped flag', () {
final b = _newBuffer(viewWidth: 5);
b.write('abc');
b.currentLine.isWrapped = true;
b.eraseLine();
expect(b.currentLine.isWrapped, isFalse);
for (var i = 0; i < 5; i++) {
expect(b.currentLine.getCodePoint(i), 0);
}
});
test('eraseLineFromCursor only clears [cursor, viewWidth)', () {
final b = _newBuffer(viewWidth: 5);
b.write('abcde');
b.setCursorX(2);
b.eraseLineFromCursor();
expect(b.currentLine.getCodePoint(0), 'a'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(1), 'b'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(2), 0);
expect(b.currentLine.getCodePoint(4), 0);
});
test('eraseLineToCursor only clears [0, cursor)', () {
final b = _newBuffer(viewWidth: 5);
b.write('abcde');
b.setCursorX(3);
b.eraseLineToCursor();
expect(b.currentLine.getCodePoint(0), 0);
expect(b.currentLine.getCodePoint(2), 0);
expect(b.currentLine.getCodePoint(3), 'd'.codeUnitAt(0));
});
test('eraseChars erases [cursor, cursor+count)', () {
final b = _newBuffer(viewWidth: 5);
b.write('abcde');
b.setCursorX(1);
b.eraseChars(2);
expect(b.currentLine.getCodePoint(0), 'a'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(1), 0);
expect(b.currentLine.getCodePoint(2), 0);
expect(b.currentLine.getCodePoint(3), 'd'.codeUnitAt(0));
});
test('eraseDisplay clears every visible line', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 3);
b.write('aaaaa');
b.lineFeed();
b.write('bbbbb');
b.eraseDisplay();
for (var y = 0; y < b.viewHeight; y++) {
for (var x = 0; x < b.viewWidth; x++) {
expect(b.lines[y].getCodePoint(x), 0);
}
}
});
test('eraseDisplayFromCursor clears current line tail + every line below', () {
// lineFeedMode: true so lineFeed resets cursorX — otherwise the
// saturated cursorX would make the next write wrap onto an extra
// line via writeChar's autoWrap path.
final b = _newBuffer(viewWidth: 5, viewHeight: 5, lineFeedMode: true);
b.write('aaaaa');
b.lineFeed();
b.write('bbbbb');
b.lineFeed();
b.write('ccccc');
b.setCursor(2, 1);
b.eraseDisplayFromCursor();
expect(b.lines[0].getCodePoint(0), 'a'.codeUnitAt(0)); // untouched
expect(b.lines[1].getCodePoint(0), 'b'.codeUnitAt(0)); // head kept
expect(b.lines[1].getCodePoint(2), 0); // tail erased
expect(b.lines[2].getCodePoint(0), 0); // line below wiped
});
test('eraseDisplayToCursor clears every line above + the current line head', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 5, lineFeedMode: true);
b.write('aaaaa');
b.lineFeed();
b.write('bbbbb');
b.lineFeed();
b.write('vwxyz'); // distinct chars so the kept tail is visible
b.setCursor(3, 2);
b.eraseDisplayToCursor();
expect(b.lines[0].getCodePoint(0), 0);
expect(b.lines[1].getCodePoint(0), 0);
expect(b.lines[2].getCodePoint(0), 0); // head erased
expect(b.lines[2].getCodePoint(3), 'y'.codeUnitAt(0)); // tail kept
expect(b.lines[2].getCodePoint(4), 'z'.codeUnitAt(0));
});
});
group('Buffer — scroll / index / lineFeed / reverseIndex', () {
test('scrollUp inside the scroll region rotates lines and seeds an empty tail', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 5, lineFeedMode: true);
b.write('aaa');
b.lineFeed();
b.write('bbb');
b.lineFeed();
b.write('ccc');
b.scrollUp(1);
// After scroll: line 0 = 'bbb', line 1 = 'ccc', line 2 = empty.
expect(b.lines[0].getCodePoint(0), 'b'.codeUnitAt(0));
expect(b.lines[1].getCodePoint(0), 'c'.codeUnitAt(0));
expect(b.lines[2].getCodePoint(0), 0);
});
test('scrollDown inside the scroll region rotates lines and seeds an empty head', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 5, lineFeedMode: true);
b.write('aaa');
b.lineFeed();
b.write('bbb');
b.scrollDown(1);
// After scroll: line 0 = empty, line 1 = 'aaa', line 2 = 'bbb'.
expect(b.lines[0].getCodePoint(0), 0);
expect(b.lines[1].getCodePoint(0), 'a'.codeUnitAt(0));
expect(b.lines[2].getCodePoint(0), 'b'.codeUnitAt(0));
});
test('index — at the bottom of primary buffer pushes a new line to grow scrollback', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 3);
b.setCursorY(2);
b.index();
expect(b.scrollBack, 1);
expect(b.cursorY, 2); // stayed at viewHeight - 1
});
test('index — at the bottom of alt buffer scrolls up instead of growing', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 3, isAltBuffer: true);
b.setCursorY(2);
b.index();
expect(b.scrollBack, 0);
expect(b.cursorY, 2);
});
test('index — within margin but not at the bottom moves cursor down', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(1, 3);
b.setCursorY(1);
b.index();
expect(b.cursorY, 2);
});
test('index — at margin bottom with non-zero top scrollUps inside the region', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 4);
b.setVerticalMargins(1, 2);
b.setCursorY(2); // at marginBottom
b.write('a'); // mark line 2
b.index();
expect(b.cursorY, 2);
});
test('index — at marginBottom with marginTop=0 on primary inserts a new line', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 4);
// marginTop=0 by default
b.setCursorY(b.marginBottom);
b.write('z');
final beforeHeight = b.height;
b.index();
expect(b.height, beforeHeight + 1); // an empty line was inserted
});
test('index — cursor outside the vertical margin and at viewport bottom on primary pushes scrollback', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 5);
// Margins shrink the scroll region — the cursor below it is outside.
b.setVerticalMargins(1, 3);
b.setCursorY(4); // viewHeight - 1, outside [1, 3]
final before = b.height;
b.index();
expect(b.height, before + 1); // primary path: lines.push
});
test('index — cursor outside the vertical margin on alt buffer scrolls up instead of pushing', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 5, isAltBuffer: true);
b.setVerticalMargins(1, 3);
b.setCursorY(4);
final before = b.height;
b.index();
expect(b.height, before); // alt path: no growth
});
test('index — cursor outside the vertical margin and not at the bottom moves cursor down', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(1, 3);
b.setCursorY(4); // outside, but not viewHeight-1... wait viewHeight-1 IS 4
// To exercise the moveCursorY branch outside the margin we need
// _cursorY < viewHeight - 1 AND outside [marginTop, marginBottom].
// With margins (1, 2) the cursor at y=3 is outside and not at the bottom.
b.setVerticalMargins(1, 2);
b.setCursorY(3);
b.index();
expect(b.cursorY, 4);
});
test('lineFeed honours lineFeedMode by setting cursor to column 0', () {
final b = _newBuffer(lineFeedMode: true);
b.setCursorX(5);
b.lineFeed();
expect(b.cursorX, 0);
});
test('lineFeed leaves cursorX alone when lineFeedMode is off', () {
final b = _newBuffer();
b.setCursorX(5);
b.lineFeed();
expect(b.cursorX, 5);
});
test('reverseIndex inside margins at top scrolls down, otherwise moves up', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(1, 3);
b.setCursorY(2);
b.reverseIndex();
expect(b.cursorY, 1);
b.setCursorY(1); // at marginTop
b.reverseIndex();
expect(b.cursorY, 1); // cursor stays; scrollDown happened
b.setCursorY(0); // outside margins
b.reverseIndex();
expect(b.cursorY, 0); // already at 0, clamps
});
});
group('Buffer — saveCursor / restoreCursor', () {
test('round-trips position + style + charset', () {
final b = _newBuffer();
b.setCursor(3, 2);
// Mutate cursor style.
// (Reach into the underlying state via the property surface.)
b.saveCursor();
b.setCursor(0, 0);
b.restoreCursor();
expect(b.cursorX, 3);
expect(b.cursorY, 2);
});
});
group('Buffer — line insert / delete / chars', () {
test('insertBlankChars opens a gap at the cursor', () {
final b = _newBuffer(viewWidth: 5);
b.write('abcde');
b.setCursorX(1);
b.insertBlankChars(2);
expect(b.currentLine.getCodePoint(1), 0);
expect(b.currentLine.getCodePoint(2), 0);
expect(b.currentLine.getCodePoint(3), 'b'.codeUnitAt(0));
});
test('deleteChars closes a gap at the cursor', () {
final b = _newBuffer(viewWidth: 5);
b.write('abcde');
b.setCursorX(1);
b.deleteChars(2);
expect(b.currentLine.getCodePoint(0), 'a'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(1), 'd'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(2), 'e'.codeUnitAt(0));
});
test('insertLines is a no-op outside the scroll region', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(2, 4);
b.setCursorY(0);
b.write('aaa');
b.insertLines(2);
// line 0 untouched (cursor was outside margins → no-op)
expect(b.lines[0].getCodePoint(0), 'a'.codeUnitAt(0));
});
test('insertLines inside the scroll region pushes lines down', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 5);
b.setVerticalMargins(1, 3);
b.setCursor(0, 2);
b.write('aaa');
b.setCursor(0, 1);
b.insertLines(1);
// insertLines(1) pushes lines below the cursor down. So 'aaa'
// should now be at line 3.
expect(b.lines[3].getCodePoint(0), 'a'.codeUnitAt(0));
// Line 1 (cursor) is now empty.
expect(b.lines[1].getCodePoint(0), 0);
});
test('insertLines fills the entire region with empty lines when count >= lines below', () {
// When linesToInsert == linesBelow, linesToMove is 0 and the
// "fill empty" loop runs from 0 to linesToInsert.
final b = _newBuffer(viewWidth: 3, viewHeight: 5);
b.setVerticalMargins(1, 3);
b.setCursor(0, 1);
b.write('aaa');
b.setCursor(0, 2);
b.write('bbb');
b.setCursor(0, 1);
// 3 lines below (incl cursor) but request many more — clamps to 3
// and skips the "move" loop entirely.
b.insertLines(99);
for (var y = 1; y <= 3; y++) {
expect(b.lines[y].getCodePoint(0), 0);
}
});
test('deleteLines is a no-op outside the scroll region', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(2, 4);
b.setCursorY(0);
b.write('aaa');
b.deleteLines(2);
expect(b.lines[0].getCodePoint(0), 'a'.codeUnitAt(0));
});
test('deleteLines inside the scroll region pulls lines up', () {
// Use setCursor (not setCursorY) so cursorX is reset between
// writes — otherwise it stays saturated after the previous write.
final b = _newBuffer(viewWidth: 3, viewHeight: 6);
b.setVerticalMargins(1, 4);
b.setCursor(0, 2);
b.write('aaa');
b.setCursor(0, 3);
b.write('bbb');
b.setCursor(0, 1);
b.deleteLines(1);
// Inside the scroll region, deleteLines at line 1 shifts lines 2..4 up.
// Original content was at lines 2 ('aaa') and 3 ('bbb'); after deletion
// they should sit at 1 and 2.
expect(b.lines[1].getCodePoint(0), 'a'.codeUnitAt(0));
expect(b.lines[2].getCodePoint(0), 'b'.codeUnitAt(0));
expect(b.lines[4].getCodePoint(0), 0); // bottom of region cleared
});
});
group('Buffer — resize', () {
test('grow height pushes empty lines while there is room in the ring', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 3, maxLines: 50);
b.resize(5, 3, 5, 5);
expect(b.height, 5);
});
test('grow height into existing scrollback bumps cursorY instead of pushing', () {
// Build up scrollback so lines.length > new viewHeight.
final b = _newBuffer(viewWidth: 3, viewHeight: 3, maxLines: 50);
// Force scrollback growth via index()-at-bottom on primary buffer.
b.setCursorY(2);
for (var i = 0; i < 5; i++) {
b.index();
}
expect(b.height, greaterThanOrEqualTo(8));
final cursorYBefore = b.cursorY;
// Resize to a height that's still smaller than lines.length: the
// grow loop should bump _cursorY rather than push new lines.
b.resize(3, 3, 3, 5);
expect(b.cursorY, greaterThan(cursorYBefore));
});
test('resize with reflow pads the result up to newHeight', () {
// Empty buffer: reflow returns near-zero lines; the pad-with-empty-
// lines branch fills up to newHeight.
final b = _newBuffer(viewWidth: 8, viewHeight: 3, reflowEnabled: true);
b.resize(8, 3, 4, 12);
expect(b.height, greaterThanOrEqualTo(12));
});
test('shrink height pops lines from the bottom when cursor is near the top', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 5);
b.setCursorY(0);
b.resize(5, 5, 5, 3);
expect(b.height, 3);
});
test('shrink height drags cursor up when it sits below the new height', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 5);
b.setCursorY(4);
b.resize(5, 5, 5, 3);
expect(b.cursorY, lessThanOrEqualTo(2));
});
test('resize without reflow forwards width to every line', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 3, reflowEnabled: false);
b.resize(5, 3, 8, 3);
for (var y = 0; y < b.viewHeight; y++) {
expect(b.lines[y].length, 8);
}
});
test('resize with reflow on primary buffer pads the result up to newHeight', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 3, reflowEnabled: true);
b.resize(5, 3, 8, 5);
expect(b.height, greaterThanOrEqualTo(5));
});
});
group('Buffer — scrollback / clear', () {
test('clearScrollback is a no-op when there is no scrollback', () {
final b = _newBuffer(viewHeight: 3);
b.clearScrollback();
expect(b.scrollBack, 0);
});
test('clearScrollback drops lines above the viewport', () {
final b = _newBuffer(viewHeight: 3);
// Force scrollback by indexing past the bottom.
b.setCursorY(2);
b.index();
b.index();
expect(b.scrollBack, 2);
b.clearScrollback();
expect(b.scrollBack, 0);
});
test('clear wipes everything and refills with viewHeight empty lines', () {
final b = _newBuffer(viewHeight: 3);
b.write('aaa');
b.clear();
expect(b.height, 3);
for (var y = 0; y < 3; y++) {
for (var x = 0; x < b.viewWidth; x++) {
expect(b.lines[y].getCodePoint(x), 0);
}
}
});
});
group('Buffer — anchors', () {
test('createAnchor at (x, y)', () {
final b = _newBuffer();
final a = b.createAnchor(2, 1);
expect(a.x, 2);
expect(b.lines[1].anchors, contains(a));
});
test('createAnchorFromOffset', () {
final b = _newBuffer();
final a = b.createAnchorFromOffset(const CellOffset(3, 2));
expect(a.x, 3);
expect(b.lines[2].anchors, contains(a));
});
test('createAnchorFromCursor', () {
final b = _newBuffer();
b.setCursor(4, 1);
final a = b.createAnchorFromCursor();
expect(a.x, 4);
});
});
group('Buffer — getWordBoundary', () {
test('returns null for an out-of-range y', () {
final b = _newBuffer(viewHeight: 3);
expect(b.getWordBoundary(const CellOffset(0, 99)), isNull);
});
test('returns null when the position is fully bounded by separators on both sides', () {
// The implementation walks left until a separator (or column 0) and
// right until a separator (or viewWidth). A null result requires the
// walks to land on the same column — i.e., separators flanking the
// position itself.
final b = _newBuffer(viewWidth: 5);
b.write('a b'); // spaces at indexes 1, 2, 3
final r = b.getWordBoundary(const CellOffset(2, 0));
expect(r, isNull);
});
test('returns the surrounding word range, halting at separators', () {
final b = _newBuffer(viewWidth: 9);
b.write('foo bar');
final r = b.getWordBoundary(const CellOffset(5, 0))!;
expect(r.begin.x, 4);
expect(r.end.x, 7);
});
test('honours custom wordSeparators when provided', () {
final b = _newBuffer(viewWidth: 5, wordSeparators: <int>{','.codeUnitAt(0)});
b.write('a,bcd');
final r = b.getWordBoundary(const CellOffset(2, 0))!;
expect(r.begin.x, 2);
expect(r.end.x, 5);
});
});
group('Buffer — getText', () {
test('default range walks the whole buffer line-by-line with newlines', () {
final b = _newBuffer(viewWidth: 4, viewHeight: 3);
b.write('aa');
b.lineFeed();
b.write('bb');
final text = b.getText();
expect(text, contains('aa'));
expect(text, contains('bb'));
expect(text.split('\n').length, greaterThanOrEqualTo(2));
});
test('explicit range works on a normalized BufferRangeLine', () {
final b = _newBuffer(viewWidth: 4, viewHeight: 3);
b.write('aa');
b.lineFeed();
b.write('bb');
final r = BufferRangeLine(const CellOffset(0, 0), const CellOffset(2, 0));
expect(b.getText(r), 'aa');
});
test('skips out-of-range segments', () {
final b = _newBuffer(viewWidth: 4, viewHeight: 2);
b.write('aa');
// Range references a y past height; getText should skip it without
// throwing.
final r = BufferRangeLine(const CellOffset(0, 0), const CellOffset(2, 99));
final result = b.getText(r);
expect(result, contains('aa'));
});
test('wrapped lines do not get an inserted newline', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 3);
b.write('abcdef'); // wraps line 1
final text = b.getText();
// The wrapped line shouldn't have a separating newline before it.
expect(text.replaceAll('\n', '').contains('abcdef'), isTrue);
});
});
group('Buffer — toString debug dump', () {
test('produces one line per buffer line with index and wrap marker', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 3);
b.write('abcd'); // wraps; line 0 is 'abc', line 1 is 'd' (wrapped)
final out = b.toString();
expect(out, contains('|abc|'));
expect(out, contains('(⏎)')); // the wrapped marker
});
});
}