todo-app/test/note_template_test.dart
Krzysztof kuhy Rudnicki abd4ba3bd7 Add full note view/editor with templates and Markdown render
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>
2026-06-15 21:59:31 +02:00

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 '), '');
});
});
}