muzzle-velocity/test/markdown_editing_test.dart

162 lines
5.9 KiB
Dart
Raw Normal View History

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:muzzle_velocity/markdown_editing.dart';
final _formatter = MarkdownInputFormatter();
TextEditingValue _value(String text, int start, [int? end]) => TextEditingValue(
text: text,
selection: TextSelection(baseOffset: start, extentOffset: end ?? start),
);
/// Simulates typing [typed] over the current selection of [old].
TextEditingValue _type(TextEditingValue old, String typed) {
final s = old.selection.start;
final raw = _value(old.text.replaceRange(s, old.selection.end, typed),
s + typed.length);
return _formatter.formatEditUpdate(old, raw);
}
/// Text with `|` marking the caret.
TextEditingValue _caret(String marked) =>
_value(marked.replaceFirst('|', ''), marked.indexOf('|'));
String _marked(TextEditingValue v) =>
v.text.replaceRange(v.selection.start, v.selection.start, '|');
Map<String, String> _classes(String text) => {
for (final s in highlightMarkdown(text))
text.substring(s.start, s.end): s.className,
};
void main() {
group('wrap selection', () {
test('asterisk wraps and keeps the word selected', () {
final once = _type(_value('a word here', 2, 6), '*');
expect(once.text, 'a *word* here');
expect(once.selection, const TextSelection(baseOffset: 3, extentOffset: 7));
expect(_type(once, '*').text, 'a **word** here');
});
test('bracket uses its closing pair', () {
expect(_type(_value('see link', 4, 8), '[').text, 'see [link]');
});
test('a letter still replaces the selection', () {
final result = _type(_value('a word', 2, 6), 'x');
expect(result.text, 'a x');
});
});
group('new line', () {
test('keeps indentation', () {
expect(_marked(_type(_caret(' code|'), '\n')), ' code\n |');
});
test('continues bullets, numbers, tasks and quotes', () {
expect(_marked(_type(_caret('- one|'), '\n')), '- one\n- |');
expect(_marked(_type(_caret(' * one|'), '\n')), ' * one\n * |');
expect(_marked(_type(_caret('9. nine|'), '\n')), '9. nine\n10. |');
expect(_marked(_type(_caret('1) a|'), '\n')), '1) a\n2) |');
expect(_marked(_type(_caret('- [x] done|'), '\n')), '- [x] done\n- [ ] |');
expect(_marked(_type(_caret('> quote|'), '\n')), '> quote\n> |');
});
test('splits an item when the caret is mid-line', () {
expect(_marked(_type(_caret('- one|two'), '\n')), '- one\n- |two');
});
test('an empty item ends the list', () {
expect(_marked(_type(_caret('- one\n- |'), '\n')), '- one\n|');
expect(_marked(_type(_caret('1. a\n2. |'), '\n')), '1. a\n|');
});
test('inside a code fence only indentation carries over', () {
expect(_marked(_type(_caret('```\n - x|'), '\n')), '```\n - x\n |');
});
test('emphasis at line start is not a bullet', () {
expect(_marked(_type(_caret('*bold* text|'), '\n')), '*bold* text\n|');
});
});
group('indentLines', () {
test('indents and outdents all selected lines', () {
final value = _value('a\nb\nc', 0, 3);
final indented = indentLines(value);
expect(indented.text, ' a\n b\nc');
expect(indentLines(indented, outdent: true).text, 'a\nb\nc');
});
});
group('toggleEmphasis', () {
test('wraps, unwraps and inserts empty pairs', () {
final bold = toggleEmphasis(_value('a word', 2, 6), '**');
expect(bold.text, 'a **word**');
expect(bold.selection, const TextSelection(baseOffset: 4, extentOffset: 8));
expect(toggleEmphasis(bold, '**').text, 'a word');
final boldItalic = toggleEmphasis(bold, '*');
expect(boldItalic.text, 'a ***word***');
expect(toggleEmphasis(boldItalic, '*').text, 'a **word**');
expect(toggleEmphasis(boldItalic, '**').text, 'a *word*');
expect(_marked(toggleEmphasis(_caret('x|'), '*')), 'x*|*');
});
});
group('highlightMarkdown', () {
test('line and inline syntax', () {
final classes = _classes(
'## Title\n- [ ] task **bold** and *it* `code` ~~gone~~\n[text](url) #tag +proj @ctx');
expect(classes['## Title'], 'header-h2');
expect(classes['- [ ] '], 'task-list');
expect(classes['**bold**'], 'bold');
expect(classes['*it*'], 'italic');
expect(classes['`code`'], 'code');
expect(classes['~~gone~~'], 'strikethrough');
expect(classes['[text]'], 'link-text');
expect(classes['(url)'], 'link-url');
expect(classes['#tag'], 'tag-hash');
expect(classes['+proj'], 'tag-plus');
expect(classes['@ctx'], 'tag-at');
});
test('fenced code spans lines without inline highlighting', () {
final classes = _classes('```\n**not bold**\n```\n**bold**');
expect(classes['**not bold**'], 'code');
expect(classes['**bold**'], 'bold');
});
test('segments are sorted and do not overlap', () {
final segments = highlightMarkdown('- a **b** #c\n> q\n---');
for (var i = 1; i < segments.length; i++) {
expect(segments[i].start, greaterThanOrEqualTo(segments[i - 1].end));
}
});
});
group('MarkdownController search', () {
test('finds terms case-insensitively and follows edits', () {
final controller =
MarkdownController(text: 'Foo bar foo #tag', theme: const {});
controller.search(['foo', '#tag']);
expect(controller.matches.map((m) => m.start), [0, 8, 12]);
controller.text = 'bar foo';
expect(controller.matches.map((m) => m.start), [4]);
});
test('builds spans covering the whole text', () {
final controller =
MarkdownController(text: '# Hi **there**', theme: const {});
controller.search(['there']);
final span = controller.buildTextSpan(
context: _FakeContext(), withComposing: false);
expect(span.toPlainText(), '# Hi **there**');
});
});
}
class _FakeContext extends Fake implements BuildContext {}