Todo stores editor documents too, so the document, the editor, its view, the format bar, links, the clipboard, scripts and EditorBlocks move, with their strings and tests, to a shared package on the same appflowy_editor fork. What is Learn's alone stays in Learn on top of it, and what the editor took from it directly is inverted: EditorBlocks gains textMarks, formatActions and onEditorClosed, RichContentEditor a wrapEditor, and an InsertableBlock's name takes the BuildContext. Learn's screens use LearnContentEditor and LearnContentView, which add Learn's blocks and the grammar check. Learn behaves as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
361 lines
9.6 KiB
Dart
361 lines
9.6 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:halkyon_editor/halkyon_editor.dart';
|
|
|
|
void main() {
|
|
/// The editor's JSON for a page with one paragraph per text.
|
|
Map<String, Object?> paragraphs(List<String> texts) => {
|
|
'document': {
|
|
'type': 'page',
|
|
'children': [
|
|
for (final text in texts)
|
|
{
|
|
'type': 'paragraph',
|
|
'data': {
|
|
'delta': [
|
|
{'insert': text},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
group('RichContent', () {
|
|
test('without a first heading that repeats a title, whatever its case '
|
|
'and spaces; anything else stays', () {
|
|
RichContent page(List<Map<String, Object?>> children) => RichContent({
|
|
'document': {'type': 'page', 'children': children},
|
|
});
|
|
Map<String, Object?> heading(String text) => {
|
|
'type': 'heading',
|
|
'data': {
|
|
'level': 1,
|
|
'delta': [
|
|
{'insert': text},
|
|
],
|
|
},
|
|
};
|
|
final text = {
|
|
'type': 'paragraph',
|
|
'data': {
|
|
'delta': [
|
|
{'insert': 'In Japanese…'},
|
|
],
|
|
},
|
|
};
|
|
|
|
expect(
|
|
page([heading('What is Kanji?'), text])
|
|
.withoutHeading('what is kanji?'),
|
|
page([text]),
|
|
);
|
|
final other = page([heading('Kanji'), text]);
|
|
expect(other.withoutHeading('What is Kanji?'), other);
|
|
final later = page([text, heading('What is Kanji?')]);
|
|
expect(later.withoutHeading('What is Kanji?'), later);
|
|
expect(other.withoutHeading(''), other);
|
|
});
|
|
|
|
test('equals content with the same JSON, whatever the key order', () {
|
|
final a = RichContent({
|
|
'document': {'type': 'page', 'children': <Object?>[]},
|
|
});
|
|
final b = RichContent({
|
|
'document': {'children': <Object?>[], 'type': 'page'},
|
|
});
|
|
|
|
expect(a, b);
|
|
expect(a.hashCode, b.hashCode);
|
|
});
|
|
|
|
test('differs when the text differs', () {
|
|
expect(
|
|
RichContent(paragraphs(['猫'])),
|
|
isNot(RichContent(paragraphs(['犬']))),
|
|
);
|
|
});
|
|
|
|
test('differs when the format version differs', () {
|
|
expect(
|
|
RichContent(paragraphs(['猫'])),
|
|
isNot(RichContent(paragraphs(['猫']), formatVersion: 0)),
|
|
);
|
|
});
|
|
|
|
test('is not affected by later changes to the JSON it was made from', () {
|
|
final json = paragraphs(['猫']);
|
|
final content = RichContent(json);
|
|
|
|
final document = json['document']! as Map<String, Object?>;
|
|
(document['children']! as List<Object?>).clear();
|
|
|
|
expect(content, RichContent(paragraphs(['猫'])));
|
|
});
|
|
|
|
test('cannot be changed through its JSON', () {
|
|
final content = RichContent(paragraphs(['猫']));
|
|
final document = content.editorJson['document']! as Map<String, Object?>;
|
|
final children = document['children']! as List<Object?>;
|
|
|
|
expect(() => document['type'] = 'heading', throwsUnsupportedError);
|
|
expect(children.clear, throwsUnsupportedError);
|
|
});
|
|
|
|
test('survives a JSON round trip, Japanese text and marks included', () {
|
|
final content = RichContent({
|
|
'document': {
|
|
'type': 'page',
|
|
'children': [
|
|
{
|
|
'type': 'heading',
|
|
'data': {
|
|
'level': 2,
|
|
'delta': [
|
|
{'insert': '日本語'},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
'type': 'paragraph',
|
|
'data': {
|
|
'delta': [
|
|
{
|
|
'insert': '猫',
|
|
'attributes': {'bold': true},
|
|
},
|
|
{'insert': 'は動物です。'},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
final stored = jsonEncode(content.toJson());
|
|
final read = RichContent.fromJson(
|
|
jsonDecode(stored) as Map<String, Object?>,
|
|
);
|
|
|
|
expect(read, content);
|
|
});
|
|
|
|
test('rejects content in a newer format than this app knows', () {
|
|
expect(
|
|
() => RichContent.fromJson({
|
|
'formatVersion': RichContent.currentFormatVersion + 1,
|
|
'editor': RichContent.empty().editorJson,
|
|
}),
|
|
throwsFormatException,
|
|
);
|
|
});
|
|
|
|
test('rejects JSON that is not rich content', () {
|
|
expect(
|
|
() => RichContent.fromJson({'formatVersion': 1, 'editor': 'text'}),
|
|
throwsFormatException,
|
|
);
|
|
expect(
|
|
() => RichContent.fromJson({'editor': RichContent.empty().editorJson}),
|
|
throwsFormatException,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("RichContent.imageIds lists the person's images it shows, in order "
|
|
'and once each; no sound, nor an image from elsewhere', () {
|
|
Map<String, Object?> block(String type, String url) => {
|
|
'type': type,
|
|
'data': {'url': url},
|
|
};
|
|
final content = RichContent({
|
|
'document': {
|
|
'type': 'page',
|
|
'children': [
|
|
block('image', 'asset:b'),
|
|
block('audio', 'asset:sound'),
|
|
block('image', 'https://example.org/cat.png'),
|
|
block('image', 'asset:a'),
|
|
block('image', 'asset:b'),
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(content.imageIds, ['b', 'a']);
|
|
});
|
|
|
|
group('RichContent.plainText', () {
|
|
test('is the text without formatting, one line per block', () {
|
|
expect(
|
|
RichContent(paragraphs(['ひらがな', 'かたかな'])).plainText,
|
|
'ひらがな\nかたかな',
|
|
);
|
|
});
|
|
|
|
test('joins the pieces of a formatted block', () {
|
|
final content = RichContent({
|
|
'document': {
|
|
'type': 'page',
|
|
'children': [
|
|
{
|
|
'type': 'paragraph',
|
|
'data': {
|
|
'delta': [
|
|
{'insert': 'big '},
|
|
{
|
|
'insert': 'cat',
|
|
'attributes': {'bold': true},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(content.plainText, 'big cat');
|
|
});
|
|
|
|
test('is empty for empty content', () {
|
|
expect(RichContent.empty().plainText, isEmpty);
|
|
});
|
|
});
|
|
|
|
group('files', () {
|
|
RichContent blocks(List<Map<String, Object?>> children) => RichContent({
|
|
'document': {'type': 'page', 'children': children},
|
|
});
|
|
|
|
test('names the asset rows its images and sounds show', () {
|
|
final content = blocks([
|
|
{
|
|
'type': 'image',
|
|
'data': {'url': assetUrl('cat')},
|
|
},
|
|
{
|
|
'type': 'audio',
|
|
'data': {'url': assetUrl('neko')},
|
|
},
|
|
{
|
|
'type': 'image',
|
|
'data': {'url': 'https://example.com/dog.png'},
|
|
},
|
|
]);
|
|
|
|
expect(content.assetIds, {'cat', 'neko'});
|
|
expect(content.hasImages, isTrue);
|
|
expect(content.hasSounds, isTrue);
|
|
});
|
|
|
|
test('content of only an image or a sound is not blank', () {
|
|
expect(
|
|
blocks([
|
|
{
|
|
'type': 'image',
|
|
'data': {'url': assetUrl('cat')},
|
|
},
|
|
]).isBlank,
|
|
isFalse,
|
|
);
|
|
expect(
|
|
blocks([
|
|
{
|
|
'type': 'audio',
|
|
'data': {'url': assetUrl('neko')},
|
|
},
|
|
]).isBlank,
|
|
isFalse,
|
|
);
|
|
expect(RichContent.plain(' ').isBlank, isTrue);
|
|
expect(RichContent.empty().isBlank, isTrue);
|
|
});
|
|
|
|
test("a block's text counts as text: a formula, a callout's title, a "
|
|
"video's caption, a study button's label, a spoiler's hidden text", () {
|
|
final content = blocks([
|
|
{
|
|
'type': mathBlockType,
|
|
'data': {mathFormulaKey: 'E = mc^2'},
|
|
},
|
|
{
|
|
'type': calloutBlockType,
|
|
'data': {calloutTitleKey: 'Mind the particle'},
|
|
'children': [
|
|
{
|
|
'type': 'paragraph',
|
|
'data': {
|
|
'delta': [
|
|
{'insert': 'は, not わ'},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
'type': youtubeBlockType,
|
|
'data': {youtubeVideoKey: 'dQw4w9WgXcQ', youtubeCaptionKey: 'Listen'},
|
|
},
|
|
{
|
|
'type': studyButtonBlockType,
|
|
'data': {
|
|
studyButtonStudysetKey: 'japanese',
|
|
studyButtonLabelKey: 'Go',
|
|
},
|
|
},
|
|
{
|
|
'type': spoilerBlockType,
|
|
'data': {
|
|
'delta': [
|
|
{'insert': 'ねこ'},
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(
|
|
content.plainText,
|
|
'E = mc^2\nMind the particle\nは, not わ\nListen\nGo\nねこ',
|
|
);
|
|
});
|
|
|
|
test('a video or a study button is something without any text; an '
|
|
'empty formula or callout is nothing', () {
|
|
expect(
|
|
blocks([
|
|
{
|
|
'type': youtubeBlockType,
|
|
'data': {youtubeVideoKey: 'dQw4w9WgXcQ'},
|
|
},
|
|
]).isBlank,
|
|
isFalse,
|
|
);
|
|
expect(
|
|
blocks([
|
|
{
|
|
'type': studyButtonBlockType,
|
|
'data': {studyButtonStudysetKey: 'japanese'},
|
|
},
|
|
]).isBlank,
|
|
isFalse,
|
|
);
|
|
expect(
|
|
blocks([
|
|
{
|
|
'type': mathBlockType,
|
|
'data': {mathFormulaKey: ' '},
|
|
},
|
|
{'type': calloutBlockType, 'data': <String, Object?>{}},
|
|
]).isBlank,
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('reads only asset URLs as files of the person', () {
|
|
expect(assetIdOf(assetUrl('cat')), 'cat');
|
|
expect(assetIdOf('asset:'), isNull);
|
|
expect(assetIdOf('https://example.com/cat.png'), isNull);
|
|
expect(assetIdOf(42), isNull);
|
|
});
|
|
});
|
|
}
|