feat(learn): confirm the notes and files a release takes

ADR 0044, D8 and D9, its first step: before publishing to Explore, the
person confirms the notes, PDFs and other files that go. Cards and
learning paths always go.

Publish's Continue leads to a step listing them by kind, none ticked at
first but those the latest release held. Each kind is ticked whole, and
an item's menu ticks everything but it or nothing but it. What isn't
ticked goes to the server as publish's leftOut. So does whatever the
sync before publishing brings that the person never saw.

The server leaves those out of the release and its counts, and leaves
out the files only they name. leftOut is optional: released apps send
none and publish everything, as before. publish.releasedMaterial names
the notes and files of the latest release, and none for a studyset not
the caller's, as for one never published.

Whats-New: Before publishing to Explore, you choose which notes and files go with the cards and learning paths.
Whats-New-De: Vor dem Veröffentlichen auf Explore wählst du, welche Notizen und Dateien mit den Karteikarten und Lernpfaden mitgehen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 01:54:16 +01:00
commit d664115c46
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 1202 additions and 38 deletions

View file

@ -2,19 +2,27 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.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/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.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/release_material.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/unsettled_item.dart';
import '../../support/fake_asset_gateway.dart';
import '../../support/fake_asset_sealing.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_pdf_source_gateway.dart';
import '../../support/fake_publish_gateway.dart';
import '../../support/memory_published_store.dart';
@ -29,11 +37,31 @@ void main() {
late CardRepository cards;
late List<String> calls;
/// What the sync before publishing brings, when set.
Future<void> Function()? onSync;
late NoteRepository notes;
late PdfSourceRepository pdfs;
setUp(() {
publishing = FakePublishGateway();
cardGateway = FakeCardGateway();
cards = CardRepository(cardGateway);
calls = [];
onSync = null;
notes = NoteRepository(FakeNoteGateway());
pdfs = PdfSourceRepository(
FakePdfSourceGateway(),
AssetRepository(
FakeAssetGateway(),
FakeFileServerGateway(),
MemoryFileCache(),
FakeAssetSealing(),
),
);
});
tearDown(() async {
await pdfs.dispose();
});
PublishBloc bloc() => PublishBloc(
@ -47,10 +75,14 @@ void main() {
),
),
cards,
NoteRepository(FakeNoteGateway()),
notes,
LearningPathRepository(FakeLearningPathGateway()),
pdfs,
studysetId: 'japanese',
sync: () async => calls.add('sync'),
sync: () async {
calls.add('sync');
await onSync?.call();
},
);
/// Publishes through a started [bloc] and returns where it ends.
@ -100,6 +132,107 @@ void main() {
await publisher.close();
});
test('the material is confirmed: nothing ticked at first but what the '
'latest release held, and what is not ticked stays out, as does '
'what the sync brings unseen (ADR 0044, D9)', () async {
final particles = (await notes.create(
'japanese',
NoteContent(title: 'Particles', body: RichContent.empty()),
)).noteId;
final verbs = (await notes.create(
'kanji',
NoteContent(title: 'Verbs', body: RichContent.empty()),
)).noteId;
final book = await pdfs.add(
'japanese',
const PdfSourceContent(
title: 'A book',
fileName: 'book.pdf',
assetId: 'asset',
),
);
final readMe = await pdfs.add(
'kanji',
const PdfSourceContent(
title: 'Read me',
fileName: 'read-me.md',
assetId: 'other',
kind: FileKind.markdown,
),
);
publishing.released['japanese'] = {book};
final publisher = bloc()..add(const PublishEventStarted());
await publisher.stream.firstWhere((state) => state is PublishStateReady);
publisher.add(
const PublishEventMaterialAsked(subtree: ['japanese', 'kanji']),
);
final asked = await publisher.stream.firstWhere(
(state) => state is PublishStateReady && state.material != null,
) as PublishStateReady;
final choice = asked.material!;
expect(choice.items, [
MaterialItem(
id: particles,
kind: MaterialKind.note,
title: 'Particles',
studysetId: 'japanese',
),
MaterialItem(
id: book,
kind: MaterialKind.pdf,
title: 'A book',
studysetId: 'japanese',
),
MaterialItem(
id: verbs,
kind: MaterialKind.note,
title: 'Verbs',
studysetId: 'kanji',
),
MaterialItem(
id: readMe,
kind: MaterialKind.file,
title: 'Read me',
studysetId: 'kanji',
),
]);
expect(choice.ticked, {book});
publisher.add(PublishEventMaterialChosen(choice.toggled(verbs)));
await publisher.stream.firstWhere(
(state) =>
state is PublishStateReady &&
(state.material?.ticked.contains(verbs) ?? false),
);
late String unseen;
onSync = () async => unseen = (await notes.create(
'japanese',
NoteContent(title: 'From the other device', body: RichContent.empty()),
)).noteId;
publisher.add(
const PublishEventRequested(
slug: 'japanese',
notes: '',
subtree: ['japanese', 'kanji'],
tagNames: null,
),
);
final ready = await publisher.stream.firstWhere(
(state) => state is PublishStateReady && state.outcome != null,
) as PublishStateReady;
expect(ready.outcome, PublishOutcome.published);
expect(publishing.publishes.single.leftOut.toSet(), {
particles,
readMe,
unseen,
});
// Asked again for the next release.
expect(ready.material, isNull);
await publisher.close();
});
test('syncs before publishing: the instance builds the release from what '
'it holds', () async {
final publisher = bloc();

View file

@ -6,6 +6,10 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.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/note/data/note_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
@ -14,6 +18,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_pdf_source_gateway.dart';
import '../../support/fake_publish_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart';
@ -28,11 +34,15 @@ void main() {
late FakeStudysetGateway studysets;
late FakeCardGateway cards;
late FakePublishGateway publishing;
late FakeNoteGateway notes;
late FakePdfSourceGateway pdfs;
setUp(() {
studysets = FakeStudysetGateway();
cards = FakeCardGateway();
publishing = FakePublishGateway();
notes = FakeNoteGateway();
pdfs = FakePdfSourceGateway();
});
/// A studyset on this device before the app starts.
@ -78,6 +88,8 @@ void main() {
),
studysetGateway: studysets,
cardGateway: cards,
noteGateway: notes,
pdfGateway: pdfs,
profileGateway: FakePublicProfileGateway(profile: profile),
publishGateway: publishing,
),
@ -99,6 +111,19 @@ void main() {
}
}
/// Leads from the sheet's form to the step confirming the material.
Future<void> toMaterial(WidgetTester tester) async {
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
}
/// Publishes through the step confirming the material, by its button
/// [publish], with nothing more ticked.
Future<void> publishThrough(WidgetTester tester, String publish) async {
await toMaterial(tester);
await tester.tap(find.text(publish));
}
testWidgets('a studyset not published is published under a name made '
"from its title, at its author's handle", (tester) async {
stored('japanese', 'Japanese', license: 'CC-BY-SA-4.0');
@ -120,14 +145,15 @@ void main() {
find.widgetWithText(TextField, 'What changed'),
'The first lessons.',
);
await tester.tap(find.text('Publish v1'));
await publishThrough(tester, 'Publish v1');
await tester.pumpAndSettle();
expect(publishing.publishes.single, (
studysetId: 'japanese',
slug: 'japanese',
notes: 'The first lessons.',
));
final published = publishing.publishes.single;
expect(
(published.studysetId, published.slug, published.notes),
('japanese', 'japanese', 'The first lessons.'),
);
expect(published.leftOut, isEmpty);
expect(find.text('Published v1.'), findsOneWidget);
expect(find.textContaining('Published · v1'), findsOneWidget);
expect(find.text('halkyon.eu/@raisondetredev/japanese'), findsOneWidget);
@ -135,6 +161,7 @@ void main() {
find.text('Fixed since you first published: others refer to it.'),
findsOneWidget,
);
await toMaterial(tester);
expect(find.text('Publish v2'), findsOneWidget);
});
@ -151,10 +178,11 @@ void main() {
expect(find.text('Description on Explore'), findsOneWidget);
expect(find.text('From N5 on.'), findsWidgets);
await tester.tap(find.text('Publish v1'));
await publishThrough(tester, 'Publish v1');
await tester.pumpAndSettle();
expect(publishing.descriptions.single?.plainText, 'From N5 on.');
await toMaterial(tester);
expect(find.text('Publish v2'), findsOneWidget);
});
@ -201,6 +229,7 @@ void main() {
tester.widget<TextField>(find.widgetWithText(TextField, 'Name')).readOnly,
isTrue,
);
await toMaterial(tester);
expect(find.text('Publish v3'), findsOneWidget);
});
@ -238,6 +267,7 @@ void main() {
expect(find.text('Taken off Explore.'), findsOneWidget);
expect(find.text('Withdrawn · copies get no updates'), findsOneWidget);
expect(find.text('Take off Explore'), findsNothing);
await toMaterial(tester);
expect(find.text('Publish v2'), findsOneWidget);
});
@ -252,6 +282,7 @@ void main() {
findsOneWidget,
);
expect(find.textContaining('Publish v'), findsNothing);
expect(find.text('Continue'), findsNothing);
await tester.tap(find.text('Make a public profile'));
await tester.pumpAndSettle();
@ -268,7 +299,7 @@ void main() {
await showStudyset(tester, 'japanese');
await openSheet(tester);
await tester.tap(find.text('Publish v1'));
await publishThrough(tester, 'Publish v1');
await tester.pumpAndSettle();
expect(
@ -288,7 +319,7 @@ void main() {
await showStudyset(tester, 'japanese');
await openSheet(tester);
await tester.tap(find.text('Publish v1'));
await publishThrough(tester, 'Publish v1');
await tester.pumpAndSettle();
expect(
@ -349,7 +380,7 @@ void main() {
await showStudyset(tester, 'japanese');
await openSheet(tester);
await tester.tap(find.text('Publish v1'));
await publishThrough(tester, 'Publish v1');
await tester.pumpAndSettle();
expect(
find.text(
@ -399,6 +430,77 @@ void main() {
await tester.tap(find.text('Retry'));
await tester.pumpAndSettle();
await toMaterial(tester);
expect(find.text('Publish v1'), findsOneWidget);
});
testWidgets('the notes and files that go are confirmed first: none ticked '
'at first, each kind ticked whole, or everything but one, or nothing '
'but one (ADR 0044, D8 and D9)', (tester) async {
stored('japanese', 'Japanese', license: 'CC-BY-SA-4.0');
stored('kanji', 'Kanji', parent: 'japanese');
Future<String> note(String studyset, String title) async =>
(await notes.insertNote(
studysetId: studyset,
position: 'a0',
payload: encodeNotePayload(
NoteContent(title: title, body: RichContent.empty()),
),
)).noteId;
final particles = await note('japanese', 'Particles');
final radicals = await note('kanji', 'Radicals');
pdfs.receiveRemote(
id: 'book',
studysetId: 'japanese',
payload: encodePdfSourcePayload(
const PdfSourceContent(
title: 'A book',
fileName: 'book.pdf',
assetId: 'asset',
),
),
);
await showStudyset(tester, 'japanese');
await openSheet(tester);
await toMaterial(tester);
expect(find.text('Notes (2)'), findsOneWidget);
expect(find.text('PDFs (1)'), findsOneWidget);
expect(find.text('Other files (0)'), findsNothing);
// A topic's names where it is.
expect(
find.descendant(
of: find.byKey(ValueKey('material-$radicals')),
matching: find.text('Kanji'),
),
findsOneWidget,
);
expect(find.text('No notes or files go'), findsOneWidget);
await tester.tap(find.text('Notes (2)'));
await tester.pumpAndSettle();
expect(find.text('2 notes and files go'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('material-menu-book')));
await tester.pumpAndSettle();
await tester.tap(find.text('Nothing but this'));
await tester.pumpAndSettle();
expect(find.text('1 note or file goes'), findsOneWidget);
await tester.tap(find.byKey(ValueKey('material-menu-$radicals')));
await tester.pumpAndSettle();
await tester.tap(find.text('Everything but this'));
await tester.pumpAndSettle();
await tester.tap(find.text('Publish v1'));
await tester.pumpAndSettle();
expect(publishing.publishes.single.leftOut, [radicals]);
expect(particles, isNot(radicals));
// The next release starts from what the latest held.
publishing.released['japanese'] = {particles, 'book'};
await toMaterial(tester);
expect(find.text('2 notes and files go'), findsOneWidget);
await tester.tap(find.text('Back'));
await tester.pumpAndSettle();
expect(find.text('Continue'), findsOneWidget);
});
}

View file

@ -0,0 +1,68 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/release_material.dart';
/// The material confirmed before publishing (ADR 0044, D8 and D9).
void main() {
const items = [
MaterialItem(
id: 'n1',
kind: MaterialKind.note,
title: 'Particles',
studysetId: 's',
),
MaterialItem(
id: 'n2',
kind: MaterialKind.note,
title: 'Verbs',
studysetId: 's',
),
MaterialItem(
id: 'p1',
kind: MaterialKind.pdf,
title: 'Book',
studysetId: 's',
),
MaterialItem(
id: 'f1',
kind: MaterialKind.file,
title: 'Read me',
studysetId: 's',
),
];
test('nothing is ticked at first but what the latest release held', () {
expect(MaterialChoice.initial(items, const {}).ticked, isEmpty);
expect(
MaterialChoice.initial(items, const {'p1', 'gone'}).ticked,
{'p1'},
);
expect(MaterialChoice.initial(items, const {}).leftOut, [
'n1',
'n2',
'p1',
'f1',
]);
});
test('a kind is ticked whole, or none of it, and says how much is', () {
final none = MaterialChoice.initial(items, const {});
expect(none.allOf(MaterialKind.note), isFalse);
final notes = none.withKind(MaterialKind.note, ticked: true);
expect(notes.ticked, {'n1', 'n2'});
expect(notes.allOf(MaterialKind.note), isTrue);
expect(notes.allOf(MaterialKind.pdf), isFalse);
expect(notes.toggled('n1').allOf(MaterialKind.note), isNull);
expect(
notes.withKind(MaterialKind.note, ticked: false).ticked,
isEmpty,
);
});
test('everything but this, and nothing but this, across the kinds', () {
final none = MaterialChoice.initial(items, const {});
expect(none.allBut('n2').ticked, {'n1', 'p1', 'f1'});
expect(none.allBut('n2').leftOut, ['n2']);
expect(none.allBut('n2').onlyThis('f1').ticked, {'f1'});
expect(none.toggled('p1').toggled('p1').ticked, isEmpty);
});
}

View file

@ -17,8 +17,20 @@ class FakePublishGateway({
/// Refusals to answer the next publishes with, the first first.
final refusals = <PublishRefused>[];
/// What each publish was asked: the studyset, name and notes.
final publishes = <({String studysetId, String slug, String notes})>[];
/// What each publish was asked: the studyset, name, notes and what it
/// left out.
final publishes =
<
({
String studysetId,
String slug,
String notes,
List<String> leftOut,
})
>[];
/// The material of each studyset's latest release, by id.
final released = <String, Set<String>>{};
/// The description for Explore each publish sent, in order.
final descriptions = <RichContent?>[];
@ -60,11 +72,20 @@ class FakePublishGateway({
);
}
@override
Future<Set<String>> releasedMaterial(String studysetId) async {
if (fail case final error?) {
throw error;
}
return released[studysetId] ?? const {};
}
@override
Future<Publication> publish(
String studysetId, {
required String slug,
required String notes,
required List<String> leftOut,
RichContent? description,
Map<String, String>? tagNames,
}) async {
@ -72,7 +93,12 @@ class FakePublishGateway({
if (fail case final error?) {
throw error;
}
publishes.add((studysetId: studysetId, slug: slug, notes: notes));
publishes.add((
studysetId: studysetId,
slug: slug,
notes: notes,
leftOut: leftOut,
));
descriptions.add(description);
if (refusals.isNotEmpty) {
throw refusals.removeAt(0);