implemented the Ticker module with a native ProseMirror document renderer and integrated API support for structured content, navigation trees, and proxied files
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/errors/ticker_content_unavailable_exception.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
||||
|
||||
void main() {
|
||||
group('TickerResponse.fromJson', () {
|
||||
test('available post carries the nested content object', () {
|
||||
final response = TickerResponse.fromJson({
|
||||
'schemaVersion': 1,
|
||||
'available': true,
|
||||
'hash': 'R123:uuid@2026-07-08T10:00:00',
|
||||
'publishedAt': '2026-07-08T10:00:00',
|
||||
'webUrl': '/ticker',
|
||||
'content': {
|
||||
'type': 'doc',
|
||||
'content': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'Hallo'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.available, isTrue);
|
||||
expect(response.schemaVersion, 1);
|
||||
expect(response.webUrl, '/ticker');
|
||||
expect(response.content, isA<Map<String, dynamic>>());
|
||||
expect(response.content!['type'], 'doc');
|
||||
});
|
||||
|
||||
test('unavailable post keeps content null and defaults safely', () {
|
||||
final response = TickerResponse.fromJson({
|
||||
'schemaVersion': 1,
|
||||
'available': false,
|
||||
'webUrl': '/ticker',
|
||||
'content': null,
|
||||
});
|
||||
|
||||
expect(response.available, isFalse);
|
||||
expect(response.content, isNull);
|
||||
expect(response.hash, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('TickerNavResponse.fromJson', () {
|
||||
test('parses sections with per-kind fields', () {
|
||||
final nav = TickerNavResponse.fromJson({
|
||||
'schemaVersion': 1,
|
||||
'navHash': 'nav-abc',
|
||||
'sections': [
|
||||
{
|
||||
'title': 'Informationen',
|
||||
'pages': [
|
||||
{'title': 'Über uns', 'slug': 'ueber-uns', 'kind': 'CONTENT'},
|
||||
{
|
||||
'title': 'Webseite',
|
||||
'slug': 'web',
|
||||
'kind': 'REDIRECT',
|
||||
'externalUrl': 'https://marianum-fulda.de',
|
||||
'externalUrlNewTab': true,
|
||||
},
|
||||
{
|
||||
'title': 'Flyer',
|
||||
'slug': 'flyer',
|
||||
'kind': 'PROXIED_FILE',
|
||||
'fileUrl': 'ticker/pages/flyer/file',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(nav.navHash, 'nav-abc');
|
||||
expect(nav.sections, hasLength(1));
|
||||
final pages = nav.sections.single.pages;
|
||||
expect(pages.map((p) => p.kind), ['CONTENT', 'REDIRECT', 'PROXIED_FILE']);
|
||||
expect(pages[1].externalUrl, 'https://marianum-fulda.de');
|
||||
expect(pages[1].externalUrlNewTab, isTrue);
|
||||
expect(pages[2].fileUrl, 'ticker/pages/flyer/file');
|
||||
});
|
||||
|
||||
test('missing sections default to empty', () {
|
||||
final nav = TickerNavResponse.fromJson({
|
||||
'schemaVersion': 1,
|
||||
'navHash': '',
|
||||
});
|
||||
expect(nav.sections, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('TickerPageResponse.fromJson', () {
|
||||
test('CONTENT page exposes the nested document', () {
|
||||
final page = TickerPageResponse.fromJson({
|
||||
'schemaVersion': 1,
|
||||
'slug': 'ueber-uns',
|
||||
'title': 'Über uns',
|
||||
'kind': 'CONTENT',
|
||||
'content': {'type': 'doc', 'content': []},
|
||||
});
|
||||
expect(page.kind, TickerPageKind.content);
|
||||
expect(page.content, isNotNull);
|
||||
});
|
||||
|
||||
test('REDIRECT page exposes the external URL', () {
|
||||
final page = TickerPageResponse.fromJson({
|
||||
'schemaVersion': 1,
|
||||
'kind': 'REDIRECT',
|
||||
'externalUrl': 'https://marianum-fulda.de',
|
||||
});
|
||||
expect(page.kind, TickerPageKind.redirect);
|
||||
expect(page.externalUrl, 'https://marianum-fulda.de');
|
||||
});
|
||||
|
||||
test('PROXIED_FILE page exposes file metadata', () {
|
||||
final page = TickerPageResponse.fromJson({
|
||||
'schemaVersion': 1,
|
||||
'kind': 'PROXIED_FILE',
|
||||
'fileUrl': 'ticker/pages/flyer/file',
|
||||
'contentType': 'application/pdf',
|
||||
'filename': 'flyer.pdf',
|
||||
});
|
||||
expect(page.kind, TickerPageKind.proxiedFile);
|
||||
expect(page.contentType, 'application/pdf');
|
||||
expect(page.filename, 'flyer.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
group('GetTickerPage error mapping', () {
|
||||
test('404 CONTENT_UNAVAILABLE maps to a typed exception with webUrl', () async {
|
||||
final requestOptions = RequestOptions(path: 'ticker/pages/foo');
|
||||
final dio = _ThrowingDio(
|
||||
DioException(
|
||||
requestOptions: requestOptions,
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response<dynamic>(
|
||||
requestOptions: requestOptions,
|
||||
statusCode: 404,
|
||||
data: {'error': 'CONTENT_UNAVAILABLE', 'webUrl': '/ticker/p/foo'},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
() => GetTickerPage('foo', dio: dio).run(),
|
||||
throwsA(
|
||||
isA<TickerContentUnavailableException>()
|
||||
.having((e) => e.webUrl, 'webUrl', '/ticker/p/foo')
|
||||
.having((e) => e.allowRetry, 'allowRetry', isFalse),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('other 404 (NOT_FOUND) does not become CONTENT_UNAVAILABLE', () async {
|
||||
final requestOptions = RequestOptions(path: 'ticker/pages/foo');
|
||||
final dio = _ThrowingDio(
|
||||
DioException(
|
||||
requestOptions: requestOptions,
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response<dynamic>(
|
||||
requestOptions: requestOptions,
|
||||
statusCode: 404,
|
||||
data: {'error': 'NOT_FOUND', 'webUrl': '/ticker/p/foo'},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
() => GetTickerPage('foo', dio: dio).run(),
|
||||
throwsA(isNot(isA<TickerContentUnavailableException>())),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Minimal fake Dio whose `get` always fails with the given exception; every
|
||||
/// other member is unused.
|
||||
class _ThrowingDio implements Dio {
|
||||
final DioException error;
|
||||
|
||||
_ThrowingDio(this.error);
|
||||
|
||||
@override
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
Object? data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
}) => Future<Response<T>>.error(error);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/app_modules.dart';
|
||||
import 'package:marianum_mobile/storage/modules_settings.dart';
|
||||
|
||||
void main() {
|
||||
ModulesSettings settingsWith(List<Modules> order) =>
|
||||
ModulesSettings(moduleOrder: order, hiddenModules: []);
|
||||
|
||||
group('effectiveModuleOrder', () {
|
||||
test('inserts modules missing from a stale persisted order at their '
|
||||
'default position', () {
|
||||
// Regression: persisted settings from before the ticker module existed
|
||||
// repeatedly made new modules vanish from bar, "Mehr" and settings list.
|
||||
final stale = Modules.values
|
||||
.where((m) => m != Modules.ticker)
|
||||
.toList();
|
||||
|
||||
final effective = AppModule.effectiveModuleOrder(settingsWith(stale));
|
||||
|
||||
expect(effective, Modules.values);
|
||||
});
|
||||
|
||||
test('inserts a missing module after its closest present predecessor', () {
|
||||
final custom = [
|
||||
Modules.files,
|
||||
Modules.timetable,
|
||||
Modules.talk,
|
||||
Modules.marianumMessage,
|
||||
];
|
||||
|
||||
final effective = AppModule.effectiveModuleOrder(settingsWith(custom));
|
||||
|
||||
expect(
|
||||
effective.indexOf(Modules.ticker),
|
||||
effective.indexOf(Modules.timetable) + 1,
|
||||
);
|
||||
expect(effective.toSet(), Modules.values.toSet());
|
||||
});
|
||||
|
||||
test('keeps a complete persisted order untouched', () {
|
||||
final order = Modules.values.reversed.toList();
|
||||
expect(AppModule.effectiveModuleOrder(settingsWith(order)), order);
|
||||
});
|
||||
|
||||
test('drops duplicates while preserving first occurrence', () {
|
||||
final order = [Modules.talk, Modules.timetable, Modules.talk];
|
||||
final effective = AppModule.effectiveModuleOrder(settingsWith(order));
|
||||
|
||||
expect(effective.where((m) => m == Modules.talk), hasLength(1));
|
||||
expect(effective.first, Modules.talk);
|
||||
expect(effective.toSet(), Modules.values.toSet());
|
||||
});
|
||||
});
|
||||
|
||||
group('reorderModuleOrder', () {
|
||||
test('moves within the displayed subset', () {
|
||||
final effective = AppModule.effectiveModuleOrder(settingsWith([]));
|
||||
|
||||
final result = AppModule.reorderModuleOrder(
|
||||
displayed: effective,
|
||||
effective: effective,
|
||||
oldIndex: 0,
|
||||
newIndex: 2,
|
||||
);
|
||||
|
||||
expect(result[2], effective[0]);
|
||||
expect(result.toSet(), effective.toSet());
|
||||
});
|
||||
|
||||
test('non-displayed modules keep their slots', () {
|
||||
// Ticker is capability-filtered from the settings list; a reorder of the
|
||||
// visible modules must not move or drop it (previously the raw persisted
|
||||
// indices were used, moving the wrong module).
|
||||
final effective = AppModule.effectiveModuleOrder(settingsWith([]));
|
||||
final displayed = effective
|
||||
.where((m) => m != Modules.ticker)
|
||||
.toList();
|
||||
final tickerSlot = effective.indexOf(Modules.ticker);
|
||||
|
||||
final result = AppModule.reorderModuleOrder(
|
||||
displayed: displayed,
|
||||
effective: effective,
|
||||
oldIndex: 0,
|
||||
newIndex: displayed.length - 1,
|
||||
);
|
||||
|
||||
expect(result[tickerSlot], Modules.ticker);
|
||||
expect(result.toSet(), effective.toSet());
|
||||
expect(
|
||||
result.where((m) => m != Modules.ticker).toList(),
|
||||
displayed.sublist(1)..add(displayed.first),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
||||
import 'package:marianum_mobile/view/pages/ticker/ticker_view.dart';
|
||||
|
||||
List<TickerNavSection> _sections() => [
|
||||
TickerNavSection(
|
||||
title: 'Infos',
|
||||
pages: [
|
||||
TickerNavPage(title: 'Über uns', slug: 'about', kind: TickerPageKind.content),
|
||||
TickerNavPage(
|
||||
title: 'Webseite',
|
||||
slug: 'web',
|
||||
kind: TickerPageKind.redirect,
|
||||
externalUrl: 'https://marianum-fulda.de',
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
Widget _host() => MaterialApp(
|
||||
home: TickerScaffold(
|
||||
sections: _sections(),
|
||||
homeBuilder: (context, onLinkTap) => const Text('HOME'),
|
||||
pageBuilder: (context, slug, onLinkTap) => Text('PAGE:$slug'),
|
||||
),
|
||||
);
|
||||
|
||||
Finder _appBarText(String text) =>
|
||||
find.descendant(of: find.byType(AppBar), matching: find.text(text));
|
||||
|
||||
// The home surface stays mounted (IndexedStack) while a page is open, so
|
||||
// "which content is shown" is the stack index, not widget presence.
|
||||
int _shownIndex(WidgetTester tester) =>
|
||||
tester.widget<IndexedStack>(find.byType(IndexedStack)).index!;
|
||||
|
||||
const String _menuTooltip = 'Open navigation menu';
|
||||
|
||||
void main() {
|
||||
group('narrow layout (< 900)', () {
|
||||
testWidgets('uses a drawer reachable via the burger button', (tester) async {
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Nav is hidden behind the drawer, not shown as a sidebar.
|
||||
expect(find.text('Über uns'), findsNothing);
|
||||
expect(find.byTooltip(_menuTooltip), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip(_menuTooltip));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Drawer), findsOneWidget);
|
||||
expect(find.text('Über uns'), findsOneWidget);
|
||||
expect(find.text('Aktuelles'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('wide layout (>= 900)', () {
|
||||
Future<void> pumpWide(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 2000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('shows a permanent sidebar and no drawer', (tester) async {
|
||||
await pumpWide(tester);
|
||||
|
||||
expect(find.byType(Drawer), findsNothing);
|
||||
expect(find.byTooltip(_menuTooltip), findsNothing);
|
||||
// Sidebar nav item is visible without any interaction.
|
||||
expect(find.text('Über uns'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('selecting a page swaps content, title and home action', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpWide(tester);
|
||||
|
||||
expect(_shownIndex(tester), 0);
|
||||
expect(find.text('PAGE:about'), findsNothing);
|
||||
expect(_appBarText('Ticker'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.home_outlined), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Über uns'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_shownIndex(tester), 1);
|
||||
expect(find.text('PAGE:about'), findsOneWidget);
|
||||
expect(_appBarText('Über uns'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.home_outlined), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.home_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_shownIndex(tester), 0);
|
||||
expect(find.text('PAGE:about'), findsNothing);
|
||||
expect(_appBarText('Ticker'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.home_outlined), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('back gesture', () {
|
||||
testWidgets('pops from a sub-page back to home via the tab navigator', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1200, 2000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Über uns'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_shownIndex(tester), 1);
|
||||
|
||||
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
||||
// The LocalHistoryEntry is what makes the enclosing tab shell delegate
|
||||
// the system back to this navigator instead of switching tabs.
|
||||
expect(navigator.canPop(), isTrue);
|
||||
|
||||
await navigator.maybePop();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_shownIndex(tester), 0);
|
||||
expect(_appBarText('Ticker'), findsOneWidget);
|
||||
expect(navigator.canPop(), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('home selection releases the back interception', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1200, 2000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Über uns'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(Icons.home_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
||||
expect(navigator.canPop(), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# ProseMirror contract fixtures
|
||||
|
||||
These JSON files are copied verbatim from the MarianumConnect backend test
|
||||
resources, which are the canonical source:
|
||||
|
||||
backend/services/ticker/src/test/resources/prosemirror-fixtures/
|
||||
|
||||
They pin the wire format shared between the backend content validator and this
|
||||
app's renderer. When the backend fixtures change, re-copy them here — do not
|
||||
hand-edit these files.
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "callout",
|
||||
"attrs": {
|
||||
"variant": "info"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": "left"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Ein wichtiger Hinweis."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "callout",
|
||||
"attrs": {
|
||||
"variant": "warning"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": "left"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Achtung."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"attrs": {
|
||||
"src": "https://example.org/bild.png",
|
||||
"alt": "Beschreibung",
|
||||
"title": "Titel",
|
||||
"width": "50%",
|
||||
"align": "center",
|
||||
"href": "https://example.org"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"attrs": {
|
||||
"src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"alt": "Einzelpixel",
|
||||
"title": null,
|
||||
"width": null,
|
||||
"align": "left",
|
||||
"href": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"textAlign": "left",
|
||||
"level": 1
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Vollständiges Dokument"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": "justify"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Absatz mit "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold"
|
||||
},
|
||||
{
|
||||
"type": "italic"
|
||||
}
|
||||
],
|
||||
"text": "fett-kursivem"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " Text"
|
||||
},
|
||||
{
|
||||
"type": "hardBreak"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "nach einem Umbruch."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "blockquote",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": "left"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Ein Zitat."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "codeBlock",
|
||||
"attrs": {
|
||||
"language": "java"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "System.out.println(\"Hallo\");"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontalRule"
|
||||
},
|
||||
{
|
||||
"type": "bulletList",
|
||||
"content": [
|
||||
{
|
||||
"type": "listItem",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": "left"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Letzter Punkt"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "bulletList",
|
||||
"content": [
|
||||
{
|
||||
"type": "listItem",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": "left"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Erster Punkt"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "listItem",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": "left"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Zweiter Punkt"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "orderedList",
|
||||
"attrs": {
|
||||
"start": 1
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "listItem",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": "left"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Eins"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "listItem",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": "left"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Zwei"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "table",
|
||||
"content": [
|
||||
{
|
||||
"type": "tableRow",
|
||||
"content": [
|
||||
{
|
||||
"type": "tableHeader",
|
||||
"attrs": {
|
||||
"colspan": 2,
|
||||
"rowspan": 1,
|
||||
"colwidth": [
|
||||
120,
|
||||
200
|
||||
]
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": "left"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Überschrift"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "tableRow",
|
||||
"content": [
|
||||
{
|
||||
"type": "tableCell",
|
||||
"attrs": {
|
||||
"colspan": 1,
|
||||
"rowspan": 1,
|
||||
"colwidth": [
|
||||
120
|
||||
]
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": "left"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Zelle A"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "tableCell",
|
||||
"attrs": {
|
||||
"colspan": 1,
|
||||
"rowspan": 1,
|
||||
"colwidth": [
|
||||
200
|
||||
]
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": "left"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Zelle B"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"textAlign": "center",
|
||||
"level": 2
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Überschrift"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"textAlign": "left"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Normal "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold"
|
||||
}
|
||||
],
|
||||
"text": "fett"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "italic"
|
||||
}
|
||||
],
|
||||
"text": "kursiv"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "strike"
|
||||
}
|
||||
],
|
||||
"text": "durchgestrichen"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "underline"
|
||||
}
|
||||
],
|
||||
"text": "unterstrichen"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "code"
|
||||
}
|
||||
],
|
||||
"text": "code()"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "highlight",
|
||||
"attrs": {
|
||||
"color": "#fef08a"
|
||||
}
|
||||
}
|
||||
],
|
||||
"text": "markiert"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "textStyle",
|
||||
"attrs": {
|
||||
"fontSize": "20px"
|
||||
}
|
||||
}
|
||||
],
|
||||
"text": "größer"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "link",
|
||||
"attrs": {
|
||||
"href": "https://example.org",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"text": "Verweis"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/widget/prosemirror/pm_document_view.dart';
|
||||
import 'package:marianum_mobile/widget/prosemirror/pm_node.dart';
|
||||
|
||||
const _pngDataUri =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=';
|
||||
|
||||
Map<String, dynamic> _kitchenSinkDoc() => {
|
||||
'type': 'doc',
|
||||
'content': [
|
||||
{
|
||||
'type': 'heading',
|
||||
'attrs': {'level': 1},
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'Heading'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'content': [
|
||||
{
|
||||
'type': 'text',
|
||||
'text': 'bold ',
|
||||
'marks': [
|
||||
{'type': 'bold'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'text',
|
||||
'text': 'link',
|
||||
'marks': [
|
||||
{
|
||||
'type': 'link',
|
||||
'attrs': {'href': 'https://example.org'},
|
||||
},
|
||||
],
|
||||
},
|
||||
{'type': 'hardBreak'},
|
||||
{
|
||||
'type': 'text',
|
||||
'text': 'code',
|
||||
'marks': [
|
||||
{'type': 'code'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'bulletList',
|
||||
'content': [
|
||||
{
|
||||
'type': 'listItem',
|
||||
'content': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'item'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'callout',
|
||||
'attrs': {'variant': 'info'},
|
||||
'content': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'note'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'codeBlock',
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'x = 1'},
|
||||
],
|
||||
},
|
||||
{'type': 'horizontalRule'},
|
||||
{
|
||||
'type': 'image',
|
||||
'attrs': {'src': _pngDataUri, 'align': 'center', 'width': '40%'},
|
||||
},
|
||||
{
|
||||
'type': 'unknownWidget',
|
||||
'content': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'fallback child'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'table',
|
||||
'content': [
|
||||
{
|
||||
'type': 'tableRow',
|
||||
'content': [
|
||||
{
|
||||
'type': 'tableHeader',
|
||||
'attrs': {'colspan': 2},
|
||||
'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'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Widget _host(Brightness brightness, {void Function(String href)? onLinkTap}) {
|
||||
return MaterialApp(
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF993333),
|
||||
brightness: brightness,
|
||||
),
|
||||
),
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: PmDocumentView(
|
||||
doc: PmNode.fromJson(_kitchenSinkDoc()),
|
||||
onLinkTap: onLinkTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('renders kitchen-sink doc in light theme without exception', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host(Brightness.light));
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Heading'), findsOneWidget);
|
||||
expect(find.text('note'), findsOneWidget);
|
||||
expect(find.text('fallback child'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders kitchen-sink doc in dark theme without exception', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host(Brightness.dark));
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Heading'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('link marks route through onLinkTap', (tester) async {
|
||||
final tapped = <String>[];
|
||||
await tester.pumpWidget(_host(Brightness.light, onLinkTap: tapped.add));
|
||||
await tester.pump();
|
||||
|
||||
final richTexts = tester.widgetList<RichText>(find.byType(RichText));
|
||||
for (final richText in richTexts) {
|
||||
if (TapGestureRecognizerHarness.tapFirstLink(richText.text)) break;
|
||||
}
|
||||
|
||||
expect(tapped, ['https://example.org']);
|
||||
});
|
||||
}
|
||||
|
||||
/// Walks a composed span tree and fires the first link recognizer it finds,
|
||||
/// returning whether one was tapped.
|
||||
class TapGestureRecognizerHarness {
|
||||
static bool tapFirstLink(InlineSpan span) {
|
||||
var tapped = false;
|
||||
span.visitChildren((child) {
|
||||
if (child is TextSpan) {
|
||||
final recognizer = child.recognizer;
|
||||
if (recognizer is TapGestureRecognizer && recognizer.onTap != null) {
|
||||
recognizer.onTap!();
|
||||
tapped = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return tapped;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/widget/prosemirror/pm_document_view.dart';
|
||||
import 'package:marianum_mobile/widget/prosemirror/pm_node.dart';
|
||||
|
||||
/// The canonical contract fixtures shared with the backend content validator
|
||||
/// (see fixtures/README.md). Every one must parse into fully-typed nodes (no
|
||||
/// PmUnknown in the content) and render in both themes without throwing.
|
||||
const _fixtures = [
|
||||
'callout.json',
|
||||
'image.json',
|
||||
'kitchen-sink.json',
|
||||
'lists.json',
|
||||
'table.json',
|
||||
'text-marks.json',
|
||||
];
|
||||
|
||||
Map<String, dynamic> _loadFixture(String name) {
|
||||
final raw = File(
|
||||
'test/widget/prosemirror/fixtures/$name',
|
||||
).readAsStringSync();
|
||||
return jsonDecode(raw) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
bool _hasUnknown(PmNode node) {
|
||||
if (node is PmUnknown) return true;
|
||||
return node.children.any(_hasUnknown);
|
||||
}
|
||||
|
||||
Widget _host(PmNode doc, Brightness brightness) => MaterialApp(
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF993333),
|
||||
brightness: brightness,
|
||||
),
|
||||
),
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(child: PmDocumentView(doc: doc)),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
for (final fixture in _fixtures) {
|
||||
test('$fixture parses without any PmUnknown in its content', () {
|
||||
final doc = PmNode.fromJson(_loadFixture(fixture));
|
||||
// The root `doc` node itself has no dedicated subclass and maps to
|
||||
// PmUnknown by design; assert none of its content nodes are unknown.
|
||||
expect(doc, isA<PmUnknown>());
|
||||
expect(
|
||||
doc.children.any(_hasUnknown),
|
||||
isFalse,
|
||||
reason: '$fixture contains an unrecognised node type',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('$fixture renders in light + dark without exception', (
|
||||
tester,
|
||||
) async {
|
||||
final doc = PmNode.fromJson(_loadFixture(fixture));
|
||||
|
||||
await tester.pumpWidget(_host(doc, Brightness.light));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpWidget(_host(doc, Brightness.dark));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user