356 lines
9.1 KiB
Dart
356 lines
9.1 KiB
Dart
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<String, dynamic> _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<PmUnknown>());
|
|
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<PmUnknown>());
|
|
expect((node as PmUnknown).rawType, 'youTubeEmbed');
|
|
expect(node.children.single, isA<PmParagraph>());
|
|
});
|
|
|
|
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<String, dynamic>,
|
|
)
|
|
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<String, dynamic>;
|
|
final doc = PmNode.fromJson(decoded);
|
|
expect(doc.children.any(_hasUnknown), isFalse);
|
|
});
|
|
}
|