refactor(content,core,app,editor,learn): Learn's content in pure Dart, for its agent
ADR 0044's D49 (amended in building it): what reads and seals Learn's content moves out of the Flutter packages, so Learn's agent, which runs without Flutter, shares it rather than repeating the privacy rules. packages/halkyon_content (new, pure Dart) takes from halkyon_app the envelope and its cipher, SealedFile, UnlockedAccountKeys, shared spaces' gateway with SharedKeys and KeyPins, FileCache and FileServerGateway, with their fakes and tests; halkyon_app exports it, so no app changes. Its rich_text.dart takes RichContent from halkyon_editor, which exports it. newAccountKeys makes an agent's key pair. halkyon_core takes DelayedDeletions and gains SyncRequests, what a synced gateway asks of the sync, which SyncService implements. apps/learn/halkyon_learn_core (new, pure Dart) takes Learn's privacy, sync, payloads and the models, codecs, gateways and repositories of studysets, cards, notes, tags and feedback, with what they reach of files, learning paths, citations and study options; the app imports them from there. A subject's icon, colour and name stay in the app (SubjectLook); what only the app shows (progress, exam coverage, the card browser, deletions' waits) stays too. Nothing changes for people using the apps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
82c777b1a5
commit
d09625d43e
1 changed files with 3517 additions and 2007 deletions
361
packages/halkyon_content/test/rich_text/rich_content_test.dart
Normal file
361
packages/halkyon_content/test/rich_text/rich_content_test.dart
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:halkyon_content/rich_text.dart';
|
||||
import 'package:test/test.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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue