halkyon/packages/halkyon_editor/test/content/rich_content_test.dart

361 lines
9.6 KiB
Dart
Raw Normal View History

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', () {
feat: a round in the order cards come, shared daily limits, and a start screen Studying: - A round is one list in the order its cards come: those done, the one shown, those to come. A card due again within the day goes back in after 4 more cards when rated Bad, at the end otherwise, so the pills above the card follow it; the buttons say "in 4 cards", "at the end", "now" (round_order.dart). - The studied studyset's daily limits hold for everything inside it together, as Anki's do for subdecks: twenty topics start 20 new cards a day in all, not 20 each. - The study header's studyset name shows whole on hover or long press. - The assistant's button sits at the lower left: in the Library across from Create studyset, in a studyset above its bar, and higher while studying, above the buttons that answer the card. A studyset: - Progress also counts the learning paths' steps done and lists the PDFs not read to their end, begun first, each opening the reader, across its topics (MaterialProgressCubit). - Next up starts with studying for the exam while one is ahead: its reviews due and the new cards its pace leaves for the day. - Learning paths tagged with a level count towards its automatic exam plan and offer it, as cards do. - A step's first heading equal to its title is dropped on import and hidden in paths imported before. Signing out, or a session the server ended, leads to a start screen (GitHub, email, without an account, another instance) rather than a new anonymous account; only the very first start makes one by itself. Settings names the kind of account and its instance. Also: the Watch screen reads the cards once for held words and their progress; its ±10 s buttons tick as the navigation bar does; a map card keeps its view as its caption changes and brings a picked region towards the middle; an empty or failed library can be pulled to sync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-17 00:39:34 +02:00
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,
);
});
});
feat(app): flashcards on append-only revisions A studyset's screen lists its cards (reorderable; a marker on cards changed on two devices) and opens a card's screen with an editor for each side. Saving appends a revision whose parents are all current heads. mergeRevisions merges several heads per side against their newest common ancestor, as decided: different sides merge automatically, the same side changed differently is a conflict. The card screen then shows every version of that side with "Keep this", and saving is possible once each conflicted side is settled, which resolves the conflict for every device. Nothing is merged silently inside a side. CardRepository watches a studyset's cards, creates at the end and moves by fractional index (fractional_indexing_dart, checked against the reference algorithm's test vectors), and deletes by blanking every revision first (ADR 0002, rule 2). Deleting a studyset now deletes its cards the same way before the studyset goes. Every person gets a CardRepository whose revisions name them as author. RichContent gains plainText for the list's previews. Tests: the merge (chains, different and same sides, conflicts, resolving, three heads, missing parents, arrival order), the payload, the repository against a fake gateway, both blocs, and the widgets, including that cancelling a delete keeps the card. The sync tests, which need the running server, cover merging and conflicts across two devices and blank revisions on the server; their device helper is now shared in test/sync/support. AGENTS.md describes the revision pattern, the fractional order, and two tooling traps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 14:41:44 +02:00
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']);
});
feat(app): flashcards on append-only revisions A studyset's screen lists its cards (reorderable; a marker on cards changed on two devices) and opens a card's screen with an editor for each side. Saving appends a revision whose parents are all current heads. mergeRevisions merges several heads per side against their newest common ancestor, as decided: different sides merge automatically, the same side changed differently is a conflict. The card screen then shows every version of that side with "Keep this", and saving is possible once each conflicted side is settled, which resolves the conflict for every device. Nothing is merged silently inside a side. CardRepository watches a studyset's cards, creates at the end and moves by fractional index (fractional_indexing_dart, checked against the reference algorithm's test vectors), and deletes by blanking every revision first (ADR 0002, rule 2). Deleting a studyset now deletes its cards the same way before the studyset goes. Every person gets a CardRepository whose revisions name them as author. RichContent gains plainText for the list's previews. Tests: the merge (chains, different and same sides, conflicts, resolving, three heads, missing parents, arrival order), the payload, the repository against a fake gateway, both blocs, and the widgets, including that cancelling a delete keeps the card. The sync tests, which need the running server, cover merging and conflicts across two devices and blank revisions on the server; their device helper is now shared in test/sync/support. AGENTS.md describes the revision pattern, the fractional order, and two tooling traps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 14:41:44 +02:00
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);
});
});
Import Anki decks with their files and history Files in content: the person's images and sounds as asset rows synced like all content, their files kept by hash on each device, uploaded after each sync, downloaded on demand and checked against their hash (core/asset/AssetRepository). Rich text names them as asset:<id> URLs, in the editor's image block and an audio block of ours; any other URL is never fetched. Sounds play through audioplayers, one at a time. Content holding only an image or a sound isn't blank, and lists show it as such. The Anki import ("Import from Anki" under Add material): .apkg and .colpkg packages in all three formats Anki has written, read on an isolate of their own. Modern packages compress everything with zstd in frames that don't record their size, which pure_zstd v1.0.0 refuses; our fork decodes them. Fields become rich text by a converter of our own (lines, lists, formatting, images, sounds, cloze deletions hidden on the front). One card per note, the files first, studysets as chosen (flatten, sub-studysets, or tags by subdeck), the notes' tags, and, if wanted, the review history, which FSRS replays: the first template's reviews to the card's schedule, a reversed note type's second to the reverse one. Imported cards say where they came from in their first revision (card schema 3); the dashboard doesn't count them as written. The test fixtures are exported by Anki itself (tool/anki/make_fixtures.py). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-02 04:52:02 +02:00
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);
});
feat(app): editor blocks: callout, spoiler, formulas, files, video, study button Blocks beyond the editor's own, each built once for editor and view: - callout: eight kinds in theme colours, kind, title and "folded at first" from its header; written in place, left with Enter on its empty last line; never inside another - spoiler: written in plain sight, hidden behind Reveal read-only - formula on its own line, written on its own screen with a preview and snippet buttons, and formulas within a line (Σ in the format bar); set by flutter_math_fork behind MathView, LaTeX's numbered environments read as their aligned forms - image and sound picked from the device, kept as synced files - YouTube video from its link, with its start and a caption: a card until played, nothing fetched before; played in place where a web view exists (youtube_player_iframe, privacy-enhanced), else opened in the browser, behind YoutubeVideos in Dependencies - study button: a studyset, a tag or all cards, with or without its topics, back first; its StudyFocus rides on the study route and narrows that session only - tables, already in the editor, now insertable everywhere An Insert button opens on the format bar, so blocks can be inserted on phones too; the desktop's / menu lists the same blocks, translated. Card faces now dock the format bar while written, so cards get blocks and formatting on phones as well. The blocks are listed in EditorBlocks: the core's, plus the features' through EditorBlocksScope. Blocks of a type the app doesn't know say they need a newer version and stay as they are, so formatVersion stays 1. Their types and keys are in content_blocks.dart, which RichContent reads: a block's text counts as text, and a video or study button isn't blank. The path import now makes formulas real ones. integration_test/youtube_player_test.dart plays a real video through the app's player on a device, which no widget test can: YouTube refuses embeds that don't say where they are embedded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-03 12:20:41 +02:00
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,
);
});
Import Anki decks with their files and history Files in content: the person's images and sounds as asset rows synced like all content, their files kept by hash on each device, uploaded after each sync, downloaded on demand and checked against their hash (core/asset/AssetRepository). Rich text names them as asset:<id> URLs, in the editor's image block and an audio block of ours; any other URL is never fetched. Sounds play through audioplayers, one at a time. Content holding only an image or a sound isn't blank, and lists show it as such. The Anki import ("Import from Anki" under Add material): .apkg and .colpkg packages in all three formats Anki has written, read on an isolate of their own. Modern packages compress everything with zstd in frames that don't record their size, which pure_zstd v1.0.0 refuses; our fork decodes them. Fields become rich text by a converter of our own (lines, lists, formatting, images, sounds, cloze deletions hidden on the front). One card per note, the files first, studysets as chosen (flatten, sub-studysets, or tags by subdeck), the notes' tags, and, if wanted, the review history, which FSRS replays: the first template's reviews to the card's schedule, a reversed note type's second to the reverse one. Imported cards say where they came from in their first revision (card schema 3); the dashboard doesn't count them as written. The test fixtures are exported by Anki itself (tool/anki/make_fixtures.py). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-02 04:52:02 +02:00
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);
});
});
}