mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 13:23:15 +02:00
Notes were previously only openable via a quick-actions sheet; you could not read or edit a note in full. Add a shared NoteEditor used by both the capture and detail screens, plus selectable templates and a rendered Markdown view. - note_template.dart: pure assemble/parse layer over a Markdown subset (# title, ## sections + italic guidance, dropping empty sections). assemble(parse(text)) is idempotent for conforming text; non-conforming / legacy / freeform text is reported so the UI falls back to raw, untouched. Two templates: llm-design-spec (default) and blank. - note_editor.dart: View / Guided / Raw modes. Guided is an inline Stepper (one step per section with its guidance); View renders the note via MarkdownView; Raw is the verbatim text. Guided is offered only for structured templates; switching to it is blocked when the raw text no longer conforms. - markdown_view.dart: lean read-only renderer for the note subset, wrapped in a SelectionArea for copy-out. - note_detail_screen.dart: full-screen note; opens in View, edits persist immediately, priority/status dropdowns, delete. - capture_screen / notes_list_screen wired to the new editor and detail screen (tap a note opens it; quick actions move to the overflow button). The editor is a view over plain text, so CRDT storage and Markdown export/sync are unaffected. 138 tests, 100% line coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
119 lines
4.1 KiB
Dart
119 lines
4.1 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:todo/data/note_template.dart';
|
|
|
|
void main() {
|
|
const spec = NoteTemplate.llmDesignSpec;
|
|
const blank = NoteTemplate.blank;
|
|
|
|
group('assemble', () {
|
|
test(
|
|
'builds # title + ## sections with italic guidance, dropping empty',
|
|
() {
|
|
final text = assemble(spec, {
|
|
'title': 'Add dark mode',
|
|
'what': 'A theme toggle.',
|
|
'must': '- respects system theme',
|
|
// everything else blank → dropped
|
|
});
|
|
|
|
expect(text, contains('# Add dark mode'));
|
|
expect(text, contains('## what'));
|
|
expect(text, contains('_${spec.sections[1].helper}_'));
|
|
expect(text, contains('A theme toggle.'));
|
|
expect(text, contains('## must'));
|
|
// Dropped empty sections leave no heading behind.
|
|
expect(text, isNot(contains('## nice')));
|
|
expect(text, isNot(contains('## refs')));
|
|
},
|
|
);
|
|
|
|
test('omits the title line when the title is blank', () {
|
|
final text = assemble(spec, {'what': 'something'});
|
|
// No `# ` title line; the body starts straight at the first section.
|
|
expect(text.startsWith('# '), isFalse);
|
|
expect(text.startsWith('## what'), isTrue);
|
|
});
|
|
|
|
test('freeform template returns the trimmed body verbatim', () {
|
|
expect(assemble(blank, {'body': 'just text\n\n'}), 'just text');
|
|
expect(assemble(blank, {}), '');
|
|
});
|
|
});
|
|
|
|
group('parse round-trip', () {
|
|
test('assemble(parse(text)) is idempotent for conforming text', () {
|
|
final text = assemble(spec, {
|
|
'title': 'Title here',
|
|
'what': 'The what.',
|
|
'done': 'I can X and Y happens',
|
|
});
|
|
|
|
final parsed = parse(spec, text);
|
|
expect(parsed.conforms, isTrue);
|
|
expect(parsed.values['title'], 'Title here');
|
|
expect(parsed.values['what'], 'The what.');
|
|
expect(parsed.values['done'], 'I can X and Y happens');
|
|
// Re-assembling the parsed values reproduces the exact text.
|
|
expect(assemble(spec, parsed.values), text);
|
|
});
|
|
|
|
test('legacy "what —" notes are reported non-conforming', () {
|
|
const legacy = 'Shutdown timer\n\nwhat — a timer\nwhere — new app';
|
|
final parsed = parse(spec, legacy);
|
|
expect(parsed.conforms, isFalse);
|
|
});
|
|
|
|
test('out-of-order / duplicate headings are non-conforming', () {
|
|
const text = '# T\n\n## done\nx\n\n## what\ny';
|
|
expect(parse(spec, text).conforms, isFalse);
|
|
|
|
const dup = '# T\n\n## what\na\n\n## what\nb';
|
|
expect(parse(spec, dup).conforms, isFalse);
|
|
});
|
|
|
|
test('stray content before the first heading is non-conforming', () {
|
|
const text = 'loose line\n\n## what\nv';
|
|
expect(parse(spec, text).conforms, isFalse);
|
|
});
|
|
|
|
test('strips leading blank lines before a section value', () {
|
|
// A raw-edited note may put blank lines (and no guidance) under a
|
|
// heading; parsing must skip those blanks to recover the value.
|
|
const text = '# T\n\n## what\n\n\nthe value';
|
|
final parsed = parse(spec, text);
|
|
expect(parsed.conforms, isTrue);
|
|
expect(parsed.values['what'], 'the value');
|
|
});
|
|
|
|
test('a ## subheading inside a value is kept, not split out', () {
|
|
final text = assemble(spec, {
|
|
'title': 'T',
|
|
'what': 'intro\n## not a real section\nmore',
|
|
});
|
|
final parsed = parse(spec, text);
|
|
expect(parsed.conforms, isTrue);
|
|
expect(parsed.values['what'], contains('## not a real section'));
|
|
});
|
|
|
|
test('freeform template always conforms with the whole text as body', () {
|
|
final parsed = parse(blank, 'anything goes\nline two');
|
|
expect(parsed.conforms, isTrue);
|
|
expect(parsed.values['body'], 'anything goes\nline two');
|
|
});
|
|
});
|
|
|
|
group('noteTitle', () {
|
|
test('strips the leading # from a heading', () {
|
|
expect(noteTitle('# My title\n\n## what\nx'), 'My title');
|
|
});
|
|
|
|
test('uses the first non-empty line for freeform notes', () {
|
|
expect(noteTitle('\n\nfirst real line\nsecond'), 'first real line');
|
|
});
|
|
|
|
test('returns empty string for blank text', () {
|
|
expect(noteTitle(' \n '), '');
|
|
});
|
|
});
|
|
}
|