Each card's foot now says who made the studyset: "By you", or for a copy of a published studyset its author's handle. Beside the star, a lock or a globe says whether it is out on Explore, and opens the Publish sheet. The instance says which of the person's studysets are published and not withdrawn (`PublishEndpoint.published`, additive: older apps don't call it), and the app remembers that in the person's folder, so the library shows it offline too; publishing, withdrawing and looking one up keep it current. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
195 lines
6.8 KiB
Dart
195 lines
6.8 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/publish/bloc/publish_bloc.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/publish/model/unsettled_item.dart';
|
|
|
|
import '../../support/fake_card_gateway.dart';
|
|
import '../../support/fake_learning_path_gateway.dart';
|
|
import '../../support/fake_note_gateway.dart';
|
|
import '../../support/fake_profile_gateway.dart';
|
|
import '../../support/fake_publish_gateway.dart';
|
|
import '../../support/memory_published_store.dart';
|
|
|
|
RichContent text(String text) => RichContent.plain(text);
|
|
|
|
CardContent card(String front, String back) =>
|
|
CardContent(front: text(front), back: text(back));
|
|
|
|
void main() {
|
|
late FakePublishGateway publishing;
|
|
late FakeCardGateway cardGateway;
|
|
late CardRepository cards;
|
|
late List<String> calls;
|
|
|
|
setUp(() {
|
|
publishing = FakePublishGateway();
|
|
cardGateway = FakeCardGateway();
|
|
cards = CardRepository(cardGateway);
|
|
calls = [];
|
|
});
|
|
|
|
PublishBloc bloc() => PublishBloc(
|
|
PublishRepository(publishing, MemoryPublishedStore()),
|
|
ProfileRepository(
|
|
FakeProfileGateway(
|
|
profile: const PublicProfile(
|
|
handle: 'raisondetredev',
|
|
address: 'raisondetredev@halkyon.eu',
|
|
),
|
|
),
|
|
),
|
|
cards,
|
|
NoteRepository(FakeNoteGateway()),
|
|
LearningPathRepository(FakeLearningPathGateway()),
|
|
studysetId: 'japanese',
|
|
sync: () async => calls.add('sync'),
|
|
);
|
|
|
|
/// Publishes through a started [bloc] and returns where it ends.
|
|
Future<PublishStateReady> publish(PublishBloc bloc) async {
|
|
bloc.add(const PublishEventStarted());
|
|
await bloc.stream.firstWhere((state) => state is PublishStateReady);
|
|
bloc.add(
|
|
const PublishEventRequested(
|
|
slug: 'japanese',
|
|
notes: 'First lessons.',
|
|
subtree: ['japanese'],
|
|
),
|
|
);
|
|
return await bloc.stream.firstWhere(
|
|
(state) => state is PublishStateReady && !state.publishing,
|
|
) as PublishStateReady;
|
|
}
|
|
|
|
/// A card in Japanese, edited on another device on [parents]' heads to
|
|
/// [content].
|
|
Future<String> editedElsewhere(
|
|
CardContent mine,
|
|
CardContent theirs,
|
|
) async {
|
|
final id = await cards.create('japanese', card('猫', 'cat'));
|
|
final created = (await cards.cardsOf('japanese')).single;
|
|
await cards.save(created, mine);
|
|
cardGateway.receiveRemoteRevision(
|
|
id: 'rev-elsewhere',
|
|
cardId: id,
|
|
parents: created.heads,
|
|
payload: encodeCardPayload(theirs),
|
|
);
|
|
return id;
|
|
}
|
|
|
|
test('reads the publication with the profile', () async {
|
|
final publisher = bloc()..add(const PublishEventStarted());
|
|
|
|
final ready = await publisher.stream.firstWhere(
|
|
(state) => state is PublishStateReady,
|
|
) as PublishStateReady;
|
|
|
|
expect(ready.publication, isNull);
|
|
expect(ready.profile!.handle, 'raisondetredev');
|
|
await publisher.close();
|
|
});
|
|
|
|
test('syncs before publishing: the instance builds the release from what '
|
|
'it holds', () async {
|
|
final publisher = bloc();
|
|
|
|
final ready = await publish(publisher);
|
|
|
|
expect(ready.outcome, PublishOutcome.published);
|
|
expect(ready.publication!.latest!.version, 1);
|
|
expect(calls, ['sync']);
|
|
expect(publishing.publishes.single.notes, 'First lessons.');
|
|
await publisher.close();
|
|
});
|
|
|
|
test('changes from two devices that merge are saved merged, and '
|
|
'publishing tries again', () async {
|
|
final id = await editedElsewhere(card('ねこ', 'cat'), card('猫', 'a cat'));
|
|
publishing.refusals.add(
|
|
PublishRefused(PublishProblem.conflicts, items: [id]),
|
|
);
|
|
final publisher = bloc();
|
|
|
|
final ready = await publish(publisher);
|
|
|
|
expect(ready.outcome, PublishOutcome.published);
|
|
expect(publishing.publishes, hasLength(2));
|
|
expect(cardGateway.written.last.parents, hasLength(2));
|
|
expect((await cards.cardsOf('japanese')).single.heads, hasLength(1));
|
|
expect(calls, ['sync', 'sync']);
|
|
await publisher.close();
|
|
});
|
|
|
|
test('changes that differ on the same side are left to the person, '
|
|
'named', () async {
|
|
final id = await editedElsewhere(card('猫', 'a cat'), card('猫', 'the cat'));
|
|
final written = cardGateway.written.length;
|
|
publishing.refusals.add(
|
|
PublishRefused(PublishProblem.conflicts, items: [id]),
|
|
);
|
|
final publisher = bloc();
|
|
|
|
final ready = await publish(publisher);
|
|
|
|
expect(ready.problem, PublishProblem.conflicts);
|
|
expect(ready.unsettled, [
|
|
UnsettledItem(
|
|
kind: UnsettledKind.card,
|
|
id: id,
|
|
studysetId: 'japanese',
|
|
title: '猫',
|
|
),
|
|
]);
|
|
expect(publishing.publishes, hasLength(1));
|
|
expect(cardGateway.written, hasLength(written));
|
|
await publisher.close();
|
|
});
|
|
|
|
test('a refusal says why; an instance out of reach, that nothing was '
|
|
'published', () async {
|
|
publishing.refusals.add(PublishRefused(PublishProblem.nothingChanged));
|
|
final publisher = bloc();
|
|
|
|
expect((await publish(publisher)).problem, PublishProblem.nothingChanged);
|
|
|
|
publishing.fail = Exception('offline');
|
|
publisher.add(
|
|
const PublishEventRequested(slug: 'japanese', notes: '', subtree: []),
|
|
);
|
|
final failed = await publisher.stream.firstWhere(
|
|
(state) => state is PublishStateReady && !state.publishing,
|
|
) as PublishStateReady;
|
|
expect(failed.outcome, PublishOutcome.failed);
|
|
expect(failed.publication, isNull);
|
|
await publisher.close();
|
|
});
|
|
|
|
group('A name from a title', () {
|
|
test('is lower-case letters and digits with hyphens between', () {
|
|
expect(slugFrom('Japanese'), 'japanese');
|
|
expect(slugFrom(' N5 — Kanji & Kana! '), 'n5-kanji-kana');
|
|
expect(slugFrom('Japanisch für Anfänger'), 'japanisch-fuer-anfaenger');
|
|
expect(slugFrom('Café crème'), 'cafe-creme');
|
|
});
|
|
|
|
test('is empty for a title without Latin letters or digits', () {
|
|
expect(slugFrom('日本語'), '');
|
|
});
|
|
|
|
test('is at most 50 characters, never ending in a hyphen', () {
|
|
final slug = slugFrom('${'a' * 49} b');
|
|
expect(slug, 'a' * 49);
|
|
});
|
|
});
|
|
}
|