import 'dart:convert'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:marianum_mobile/widget/prosemirror/pm_node.dart'; /// A 1x1 transparent PNG as a base64 data URI (valid). const _pngDataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII='; /// A realistic TipTap `getJSON()` document exercising the whole vocabulary. Map _kitchenSinkDoc() => { 'type': 'doc', 'content': [ { 'type': 'heading', 'attrs': {'level': 1, 'textAlign': 'center'}, 'content': [ {'type': 'text', 'text': 'Title'}, ], }, { 'type': 'paragraph', 'attrs': {'textAlign': 'left'}, 'content': [ { 'type': 'text', 'text': 'bold', 'marks': [ {'type': 'bold'}, ], }, {'type': 'text', 'text': ' and '}, { 'type': 'text', 'text': 'link', 'marks': [ { 'type': 'link', 'attrs': {'href': 'https://example.org', 'target': '_blank'}, }, ], }, {'type': 'hardBreak'}, { 'type': 'text', 'text': 'sized highlighted', 'marks': [ { 'type': 'textStyle', 'attrs': {'fontSize': '1.5em'}, }, { 'type': 'highlight', 'attrs': {'color': '#ff0'}, }, ], }, ], }, { 'type': 'bulletList', 'content': [ { 'type': 'listItem', 'content': [ { 'type': 'paragraph', 'content': [ {'type': 'text', 'text': 'one'}, ], }, ], }, ], }, { 'type': 'orderedList', 'attrs': {'start': 3}, 'content': [ { 'type': 'listItem', 'content': [ { 'type': 'paragraph', 'content': [ {'type': 'text', 'text': 'three'}, ], }, ], }, ], }, { 'type': 'blockquote', 'content': [ { 'type': 'paragraph', 'content': [ {'type': 'text', 'text': 'quoted'}, ], }, ], }, { 'type': 'codeBlock', 'attrs': {'language': 'dart'}, 'content': [ {'type': 'text', 'text': 'void main() {}'}, ], }, {'type': 'horizontalRule'}, { 'type': 'callout', 'attrs': {'variant': 'warning'}, 'content': [ { 'type': 'paragraph', 'content': [ {'type': 'text', 'text': 'careful'}, ], }, ], }, { 'type': 'image', 'attrs': { 'src': _pngDataUri, 'alt': 'dot', 'align': 'center', 'width': '50%', 'href': 'https://example.org', }, }, { 'type': 'table', 'content': [ { 'type': 'tableRow', 'content': [ { 'type': 'tableHeader', 'attrs': {'colspan': 2, 'rowspan': 1}, 'content': [ { 'type': 'paragraph', 'content': [ {'type': 'text', 'text': 'head'}, ], }, ], }, ], }, { 'type': 'tableRow', 'content': [ { 'type': 'tableCell', 'content': [ { 'type': 'paragraph', 'content': [ {'type': 'text', 'text': 'a'}, ], }, ], }, { 'type': 'tableCell', 'content': [ { 'type': 'paragraph', 'content': [ {'type': 'text', 'text': 'b'}, ], }, ], }, ], }, ], }, ], }; bool _hasUnknown(PmNode node) { if (node is PmUnknown) return true; return node.children.any(_hasUnknown); } void main() { test('kitchen-sink doc parses without any PmUnknown', () { final doc = PmNode.fromJson(_kitchenSinkDoc()); // The root `doc` node has no dedicated subclass by design and maps to // PmUnknown; assert instead that no *content* node is unknown. expect(doc, isA()); expect(doc.children.any(_hasUnknown), isFalse); }); test('every top-level node maps to its typed subclass', () { final doc = PmNode.fromJson(_kitchenSinkDoc()); final types = doc.children.map((n) => n.runtimeType).toList(); expect(types, [ PmHeading, PmParagraph, PmBulletList, PmOrderedList, PmBlockquote, PmCodeBlock, PmHorizontalRule, PmCallout, PmImage, PmTable, ]); }); test('unknown node type becomes PmUnknown without throwing', () { final node = PmNode.fromJson({ 'type': 'youTubeEmbed', 'attrs': {'videoId': 'abc'}, 'content': [ { 'type': 'paragraph', 'content': [ {'type': 'text', 'text': 'caption'}, ], }, ], }); expect(node, isA()); expect((node as PmUnknown).rawType, 'youTubeEmbed'); expect(node.children.single, isA()); }); test('heading level and align parse and clamp', () { final node = PmNode.fromJson({ 'type': 'heading', 'attrs': {'level': 9, 'textAlign': 'right'}, }) as PmHeading; expect(node.level, 6); expect(node.align, TextAlign.right); }); test('orderedList start defaults to 1 and honours attr', () { final withStart = PmNode.fromJson({ 'type': 'orderedList', 'attrs': {'start': 5}, }) as PmOrderedList; final without = PmNode.fromJson({'type': 'orderedList'}) as PmOrderedList; expect(withStart.start, 5); expect(without.start, 1); }); test('marks parse with attrs; unknown marks are dropped tolerantly', () { final node = PmNode.fromJson({ 'type': 'text', 'text': 'x', 'marks': [ {'type': 'bold'}, { 'type': 'link', 'attrs': {'href': 'mailto:a@b.de'}, }, {'type': 42}, { 'type': 'superscript', 'attrs': {'foo': 'bar'}, }, ], }) as PmText; final markTypes = node.marks.map((m) => m.type).toList(); expect(markTypes, contains('bold')); expect(markTypes, contains('link')); expect(markTypes, contains('superscript')); expect(markTypes, isNot(contains(''))); final link = node.marks.firstWhere((m) => m.type == 'link'); expect(link.attrs['href'], 'mailto:a@b.de'); }); test('valid base64 data image is decoded once into bytes', () { final image = PmNode.fromJson({ 'type': 'image', 'attrs': {'src': _pngDataUri}, }) as PmImage; expect(image.bytes, isNotNull); expect(image.bytes!.isNotEmpty, isTrue); }); test('broken base64 data image does not crash and yields null bytes', () { final image = PmNode.fromJson({ 'type': 'image', 'attrs': {'src': 'data:image/png;base64,@@@not-base64@@@'}, }) as PmImage; expect(image.bytes, isNull); expect(image.src, startsWith('data:image/png')); }); test('network image keeps null bytes', () { final image = PmNode.fromJson({ 'type': 'image', 'attrs': {'src': 'https://example.org/a.png'}, }) as PmImage; expect(image.bytes, isNull); }); test('callout variant parses; invalid falls back to info', () { final ok = PmNode.fromJson({ 'type': 'callout', 'attrs': {'variant': 'success'}, }) as PmCallout; final bad = PmNode.fromJson({ 'type': 'callout', 'attrs': {'variant': 'nope'}, }) as PmCallout; expect(ok.variant, PmCalloutVariant.success); expect(bad.variant, PmCalloutVariant.info); }); test('table cells carry header flag and spans', () { final table = PmNode.fromJson( (_kitchenSinkDoc()['content'] as List).last as Map, ) as PmTable; final firstRow = table.children.first as PmTableRow; final header = firstRow.children.first as PmTableCell; expect(header.header, isTrue); expect(header.colspan, 2); }); test('parsing survives a JSON string round-trip', () { final raw = jsonEncode(_kitchenSinkDoc()); final decoded = jsonDecode(raw) as Map; final doc = PmNode.fromJson(decoded); expect(doc.children.any(_hasUnknown), isFalse); }); }