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 _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('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 {}