Files
clide/test/terminal/ui/ui_pure_test.dart
T
jpmschweitzerandClaude ecf8ee56af
test / unit + widget + golden + a11y (push) Failing after 48s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m0s
test sweep: cover ui/ pure-Dart files (T-91)
Adds test/terminal/ui/ui_pure_test.dart — 39 unit tests covering
the lib/src/terminal/src/ui/ files that don't need a Flutter widget
harness.

Files at 100%:
- palette_builder.dart: 0/52 → 52/52 (named-colour mapping for
  indices 0..15, RGB cube walk for 16..231 with the 95-then-+40
  step pattern, grayscale ramp 232..255, out-of-range clamp).
- paragraph_cache.dart: 2/22 → 22/22 (LRU promotion on read,
  eviction at maxSize, miss returns null, clear empties).
- terminal_text_style.dart: 7/19 → 19/19 (default ctor,
  fromTextStyle fontFamily fallback chain + size/height pass-
  through, toTextStyle attribute threading, copyWith with override
  + no-args clone).
- input_map.dart: 8/20 → 20/20 (keyToTerminalKey hits + null on
  unmapped LogicalKeyboardKey id, charToTerminalKey case-fold +
  multi-char/unknown null).
- terminal_size.dart: 4/8 → 8/8 (operator==/hashCode/toString,
  identity + type-mismatch branches).
- pointer_input.dart: 1/3 → 3/3 (default ctor, .none(), .all()).
- terminal_theme.dart: 1/1 unchanged.
- char_metrics.dart: 0/10 → 10/10 (positive width/height, monotonic
  text-scaler scaling).
- ui/controller.dart: 18/52 → 52/52 (TerminalController default
  state, setSelection + mode override, attached/detached anchor
  selection-getter behaviour, prior-anchor disposal on second
  setSelection, setSelectionMode no-op short-circuit, clearSelection,
  setPointerInputs / setSuspendPointerInput gating,
  highlight register/dispose lifecycle, TerminalHighlight.range
  null on detached anchors).

Enums (selection_mode, cursor_type) and themes constants are
covered transitively — they have no executable lines that lcov
instruments.

Coverage delta:
- Total project: 67.06% → 68.80%; coverage_floor bumped 66 → 68.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-08 11:28:17 +02:00

439 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Pure-Dart tests for `lib/src/terminal/src/ui/` files that don't
/// require a Flutter widget harness — palette construction, paragraph
/// cache, text style, key/char mapping, controller state machine,
/// small enums + value classes, char-metrics and theme constants.
library;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
import 'package:clide/src/terminal/src/core/buffer/line.dart';
import 'package:clide/src/terminal/src/core/buffer/range_block.dart';
import 'package:clide/src/terminal/src/core/buffer/range_line.dart';
import 'package:clide/src/terminal/src/core/input/keys.dart';
import 'package:clide/src/terminal/src/utils/circular_buffer.dart';
import 'package:clide/src/terminal/src/ui/char_metrics.dart';
import 'package:clide/src/terminal/src/ui/controller.dart';
import 'package:clide/src/terminal/src/ui/cursor_type.dart';
import 'package:clide/src/terminal/src/ui/input_map.dart';
import 'package:clide/src/terminal/src/ui/palette_builder.dart';
import 'package:clide/src/terminal/src/ui/paragraph_cache.dart';
import 'package:clide/src/terminal/src/ui/pointer_input.dart';
import 'package:clide/src/terminal/src/ui/selection_mode.dart';
import 'package:clide/src/terminal/src/ui/terminal_size.dart';
import 'package:clide/src/terminal/src/ui/terminal_text_style.dart';
import 'package:clide/src/terminal/src/ui/themes.dart';
void main() {
group('PaletteBuilder', () {
final builder = PaletteBuilder(TerminalThemes.defaultTheme);
test('build() returns exactly 256 colors', () {
final palette = builder.build();
expect(palette, hasLength(256));
});
test('first 16 indices map directly to the named theme colors', () {
expect(builder.paletteColor(0), TerminalThemes.defaultTheme.black);
expect(builder.paletteColor(1), TerminalThemes.defaultTheme.red);
expect(builder.paletteColor(2), TerminalThemes.defaultTheme.green);
expect(builder.paletteColor(3), TerminalThemes.defaultTheme.yellow);
expect(builder.paletteColor(4), TerminalThemes.defaultTheme.blue);
expect(builder.paletteColor(5), TerminalThemes.defaultTheme.magenta);
expect(builder.paletteColor(6), TerminalThemes.defaultTheme.cyan);
expect(builder.paletteColor(7), TerminalThemes.defaultTheme.white);
expect(builder.paletteColor(8), TerminalThemes.defaultTheme.brightBlack);
expect(builder.paletteColor(9), TerminalThemes.defaultTheme.brightRed);
expect(builder.paletteColor(10), TerminalThemes.defaultTheme.brightGreen);
expect(builder.paletteColor(11), TerminalThemes.defaultTheme.brightYellow);
expect(builder.paletteColor(12), TerminalThemes.defaultTheme.brightBlue);
expect(builder.paletteColor(13), TerminalThemes.defaultTheme.brightMagenta);
expect(builder.paletteColor(14), TerminalThemes.defaultTheme.brightCyan);
expect(builder.paletteColor(15), TerminalThemes.defaultTheme.white);
});
test('index 16 is the start of the 6×6×6 RGB cube — pure black at (0,0,0)', () {
// The cube starts with r=g=b=0 and walks the b-axis first by 95, then 40.
expect(builder.paletteColor(16), const Color.fromARGB(0xFF, 0, 0, 0));
});
test('cube walks blue → green → red, with first step 95 and subsequent +40', () {
// Index 17: b becomes 95.
expect(builder.paletteColor(17), const Color.fromARGB(0xFF, 0, 0, 95));
// Index 18: b += 40 → 135.
expect(builder.paletteColor(18), const Color.fromARGB(0xFF, 0, 0, 135));
});
test('231 is the cube terminus — pure white-ish corner', () {
// After a full 6-step walk on each axis, the 6³ cube ends at (255,255,255).
expect(builder.paletteColor(231), const Color.fromARGB(0xFF, 255, 255, 255));
});
test('grayscale ramp at 232..255', () {
expect(builder.paletteColor(232), const Color(0xff080808));
expect(builder.paletteColor(255), const Color(0xffeeeeee));
});
test('out-of-range indices clamp to grayscale endpoints', () {
// The implementation falls through to grayscale with clamp(232, 255).
expect(builder.paletteColor(999), const Color(0xffeeeeee));
});
});
group('ParagraphCache', () {
test('caches and retrieves a paragraph by integer key', () {
final cache = ParagraphCache(8);
const style = TextStyle(fontSize: 14);
final p = cache.performAndCacheLayout('a', style, TextScaler.noScaling, 1);
expect(cache.getLayoutFromCache(1), same(p));
expect(cache.length, 1);
});
test('moves recently-accessed entries to the end (LRU promotion)', () {
final cache = ParagraphCache(2);
const style = TextStyle(fontSize: 14);
cache.performAndCacheLayout('a', style, TextScaler.noScaling, 1);
cache.performAndCacheLayout('b', style, TextScaler.noScaling, 2);
// Touch key 1 — promotes it.
cache.getLayoutFromCache(1);
// Inserting a third entry evicts the LRU (key 2).
cache.performAndCacheLayout('c', style, TextScaler.noScaling, 3);
expect(cache.getLayoutFromCache(1), isNotNull);
expect(cache.getLayoutFromCache(2), isNull);
expect(cache.getLayoutFromCache(3), isNotNull);
});
test('clear empties the cache', () {
final cache = ParagraphCache(4);
const style = TextStyle(fontSize: 14);
cache.performAndCacheLayout('a', style, TextScaler.noScaling, 1);
expect(cache.length, 1);
cache.clear();
expect(cache.length, 0);
expect(cache.getLayoutFromCache(1), isNull);
});
test('miss on a never-set key returns null', () {
final cache = ParagraphCache(2);
expect(cache.getLayoutFromCache(99), isNull);
});
});
group('TerminalStyle', () {
test('default constructor uses the documented defaults', () {
const s = TerminalStyle();
expect(s.fontSize, 13.0);
expect(s.height, 1.2);
expect(s.fontFamily, 'monospace');
expect(s.fontFamilyFallback, contains('Menlo'));
});
test('fromTextStyle picks fontFamily, falling back through fontFamilyFallback', () {
final s1 = TerminalStyle.fromTextStyle(const TextStyle(fontFamily: 'Hack'));
expect(s1.fontFamily, 'Hack');
final s2 = TerminalStyle.fromTextStyle(const TextStyle(fontFamilyFallback: ['Mono', 'Courier']));
expect(s2.fontFamily, 'Mono');
final s3 = TerminalStyle.fromTextStyle(const TextStyle());
expect(s3.fontFamily, 'monospace');
});
test('fromTextStyle picks fontSize / height / fontFamilyFallback through to TerminalStyle', () {
final s = TerminalStyle.fromTextStyle(
const TextStyle(fontSize: 18, height: 1.5, fontFamilyFallback: ['Mono']),
);
expect(s.fontSize, 18);
expect(s.height, 1.5);
expect(s.fontFamilyFallback, ['Mono']);
});
test('toTextStyle threads attribute flags into the resulting TextStyle', () {
const s = TerminalStyle(fontSize: 16);
final t = s.toTextStyle(
color: const Color(0xFF112233),
bold: true,
italic: true,
underline: true,
);
expect(t.fontSize, 16);
expect(t.fontWeight, FontWeight.bold);
expect(t.fontStyle, FontStyle.italic);
expect(t.decoration, TextDecoration.underline);
expect(t.color, const Color(0xFF112233));
});
test('copyWith overrides only specified fields', () {
const s = TerminalStyle(fontSize: 14);
final copied = s.copyWith(fontSize: 20);
expect(copied.fontSize, 20);
expect(copied.fontFamily, s.fontFamily); // unchanged
});
test('copyWith with no arguments returns a value-equal clone', () {
// Hits the `?? this.X` right-hand side for every parameter.
const s = TerminalStyle(fontSize: 14);
final clone = s.copyWith();
expect(clone.fontSize, s.fontSize);
expect(clone.height, s.height);
expect(clone.fontFamily, s.fontFamily);
expect(clone.fontFamilyFallback, s.fontFamilyFallback);
});
});
group('input_map', () {
test('keyToTerminalKey maps logical keys 1:1', () {
expect(keyToTerminalKey(LogicalKeyboardKey.arrowUp), TerminalKey.arrowUp);
expect(keyToTerminalKey(LogicalKeyboardKey.keyA), TerminalKey.keyA);
expect(keyToTerminalKey(LogicalKeyboardKey.escape), TerminalKey.escape);
});
test('keyToTerminalKey returns null for an unmapped logical key', () {
// gameButtonStart is mapped; pick something that is not — there's no
// TerminalKey for `LogicalKeyboardKey.colon`, etc. Verify by walking
// a synthesized key id.
const synthetic = LogicalKeyboardKey(0xDEADBEEF);
expect(keyToTerminalKey(synthetic), isNull);
});
test('charToTerminalKey maps a single ASCII char (case-insensitive)', () {
expect(charToTerminalKey('a'), TerminalKey.keyA);
expect(charToTerminalKey('A'), TerminalKey.keyA);
expect(charToTerminalKey('1'), TerminalKey.digit1);
});
test('charToTerminalKey returns null for multi-char strings or unknown chars', () {
expect(charToTerminalKey('ab'), isNull);
expect(charToTerminalKey(''), isNull);
// Some printable characters don't have keytab mappings (e.g. ¥ ñ).
expect(charToTerminalKey('¥'), isNull);
});
});
group('TerminalSize', () {
test('value semantics — operator== / hashCode / toString', () {
const a = TerminalSize(80, 24);
const b = TerminalSize(80, 24);
const c = TerminalSize(100, 24);
expect(a, b);
expect(a.hashCode, b.hashCode);
expect(a, isNot(c));
expect(a.toString(), 'TerminalSize(80, 24)');
// identity branch
expect(identical(a, a), isTrue);
// type-mismatch branch (typed Object so we don't trip the unrelated lint)
const Object notASize = 'not a size';
expect(a == notASize, isFalse);
});
});
group('PointerInputs', () {
test('default constructor wraps the supplied set', () {
const p = PointerInputs({PointerInput.tap, PointerInput.drag});
expect(p.inputs, {PointerInput.tap, PointerInput.drag});
});
test('.none() is empty; .all() contains every PointerInput value', () {
expect(const PointerInputs.none().inputs, isEmpty);
expect(const PointerInputs.all().inputs, PointerInput.values.toSet());
});
});
group('SelectionMode + TerminalCursorType enums', () {
test('all enum values exist and are distinct', () {
expect(SelectionMode.values, [SelectionMode.line, SelectionMode.block]);
expect(TerminalCursorType.values, [
TerminalCursorType.block,
TerminalCursorType.underline,
TerminalCursorType.verticalBar,
]);
});
});
group('TerminalThemes (bundled themes are well-formed)', () {
test('defaultTheme exposes the documented core palette', () {
const t = TerminalThemes.defaultTheme;
expect(t.foreground, const Color(0XFFCCCCCC));
expect(t.background, const Color(0XFF1E1E1E));
expect(t.brightWhite, const Color(0XFFFFFFFF));
});
test('whiteOnBlack and any other bundled themes are valid TerminalTheme instances', () {
// Touch every static field that PaletteBuilder needs so that any
// missing field would surface as an error here, before runtime.
const t = TerminalThemes.whiteOnBlack;
final palette = PaletteBuilder(t).build();
expect(palette, hasLength(256));
});
});
group('calcCharSize (char_metrics)', () {
test('returns positive-width / positive-height for a default style', () {
const style = TerminalStyle();
final size = calcCharSize(style, TextScaler.noScaling);
expect(size.width, greaterThan(0));
expect(size.height, greaterThan(0));
});
test('text-scaler scales the size monotonically', () {
const style = TerminalStyle();
final small = calcCharSize(style, const TextScaler.linear(1.0));
final big = calcCharSize(style, const TextScaler.linear(2.0));
expect(big.width, greaterThan(small.width));
expect(big.height, greaterThan(small.height));
});
});
group('TerminalController', () {
test('default state: no selection, no highlights, line mode, tap-only inputs', () {
final c = TerminalController();
addTearDown(c.dispose);
expect(c.selection, isNull);
expect(c.highlights, isEmpty);
expect(c.selectionMode, SelectionMode.line);
expect(c.suspendedPointerInputs, isFalse);
expect(c.pointerInput.inputs, {PointerInput.tap});
});
test('setSelection records anchors and notifies listeners', () {
final c = TerminalController();
addTearDown(c.dispose);
var notifs = 0;
c.addListener(() => notifs++);
// Build a pair of attached anchors via a ring + line.
final ring = IndexAwareCircularBuffer<BufferLine>(4);
final line = BufferLine(8);
ring.push(line);
final base = line.createAnchor(0);
final extent = line.createAnchor(2);
c.setSelection(base, extent);
expect(notifs, 1);
expect(c.selection, isA<BufferRangeLine>());
expect(c.selection!.begin, const CellOffset(0, 0));
expect(c.selection!.end, const CellOffset(2, 0));
});
test('setSelection mode override switches to block range', () {
final c = TerminalController();
addTearDown(c.dispose);
final ring = IndexAwareCircularBuffer<BufferLine>(4);
final line = BufferLine(8);
ring.push(line);
final base = line.createAnchor(0);
final extent = line.createAnchor(3);
c.setSelection(base, extent, mode: SelectionMode.block);
expect(c.selectionMode, SelectionMode.block);
expect(c.selection, isA<BufferRangeBlock>());
});
test('selection getter returns null when an anchor is detached', () {
final c = TerminalController();
addTearDown(c.dispose);
// Anchors not attached to a circular buffer are detached by definition.
final detachedLine = BufferLine(8);
final base = detachedLine.createAnchor(0);
final extent = detachedLine.createAnchor(2);
c.setSelection(base, extent);
expect(c.selection, isNull);
});
test('setSelection a second time disposes the previous anchors', () {
final c = TerminalController();
addTearDown(c.dispose);
final ring = IndexAwareCircularBuffer<BufferLine>(4);
final line = BufferLine(8);
ring.push(line);
final base1 = line.createAnchor(0);
final extent1 = line.createAnchor(2);
c.setSelection(base1, extent1);
expect(line.anchors.length, 2);
// Replace with new anchors — old ones should be dropped from the line.
final base2 = line.createAnchor(3);
final extent2 = line.createAnchor(5);
c.setSelection(base2, extent2);
expect(line.anchors.length, 2);
expect(line.anchors.contains(base2), isTrue);
expect(line.anchors.contains(extent2), isTrue);
expect(line.anchors.contains(base1), isFalse);
});
test('setSelectionMode is a no-op when the new mode equals the old', () {
final c = TerminalController();
addTearDown(c.dispose);
var notifs = 0;
c.addListener(() => notifs++);
c.setSelectionMode(SelectionMode.line); // same as default
expect(notifs, 0);
c.setSelectionMode(SelectionMode.block);
expect(notifs, 1);
expect(c.selectionMode, SelectionMode.block);
});
test('clearSelection drops anchors and notifies', () {
final c = TerminalController();
addTearDown(c.dispose);
final ring = IndexAwareCircularBuffer<BufferLine>(4);
final line = BufferLine(8);
ring.push(line);
c.setSelection(line.createAnchor(0), line.createAnchor(2));
var notifs = 0;
c.addListener(() => notifs++);
c.clearSelection();
expect(c.selection, isNull);
expect(notifs, 1);
});
test('setPointerInputs and setSuspendPointerInput notify and update the gate', () {
final c = TerminalController();
addTearDown(c.dispose);
var notifs = 0;
c.addListener(() => notifs++);
c.setPointerInputs(const PointerInputs({PointerInput.scroll, PointerInput.drag}));
expect(notifs, 1);
expect(c.shouldSendPointerInput(PointerInput.scroll), isTrue);
expect(c.shouldSendPointerInput(PointerInput.tap), isFalse);
c.setSuspendPointerInput(true);
expect(c.suspendedPointerInputs, isTrue);
expect(notifs, 2);
// Suspended → all queries return false even if the input is in the set.
expect(c.shouldSendPointerInput(PointerInput.scroll), isFalse);
});
test('highlight registers + auto-removes via Disposable', () {
final c = TerminalController();
addTearDown(c.dispose);
final ring = IndexAwareCircularBuffer<BufferLine>(4);
final line = BufferLine(8);
ring.push(line);
final p1 = line.createAnchor(0);
final p2 = line.createAnchor(3);
final h = c.highlight(p1: p1, p2: p2, color: const Color(0xFF112233));
expect(c.highlights, contains(h));
// Range from highlight resolves to the same span via attached anchors.
expect(h.range, isA<BufferRangeLine>());
h.dispose();
expect(c.highlights, isNot(contains(h)));
});
test('TerminalHighlight.range returns null when its anchors are detached', () {
final c = TerminalController();
addTearDown(c.dispose);
// Anchors on a non-attached line.
final freeLine = BufferLine(8);
final p1 = freeLine.createAnchor(0);
final p2 = freeLine.createAnchor(2);
final h = c.highlight(p1: p1, p2: p2, color: const Color(0xFFAA0000));
expect(h.range, isNull);
});
});
}