chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
import 'package:material_ui/material_ui.dart';
void main() {
testWidgets('a change of languages keeps what is shown below: the screen '
'goes on as it was', (tester) async {
Widget scoped(StudysetLanguages languages) => MaterialApp(
home: LanguageScope(languages: languages, child: const _Counter()),
);
await tester.pumpWidget(scoped((front: 'de', back: 'en')));
await tester.tap(find.byType(TextButton));
await tester.pump();
expect(find.text('1'), findsOneWidget);
await tester.pumpWidget(scoped((front: 'zh-Hant', back: 'en')));
expect(find.text('1'), findsOneWidget);
});
}
/// Counts taps: a state that a rebuilt subtree would lose.
class const _Counter() extends StatefulWidget {
@override
State<_Counter> createState() => _CounterState();
}
class _CounterState() extends State<_Counter> {
var _count = 0;
@override
Widget build(BuildContext context) => TextButton(
onPressed: () => setState(() => _count++),
child: Text('$_count'),
);
}

View file

@ -0,0 +1,855 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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/publish/model/publication.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.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';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/deleted_studyset_card.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_file_picker.dart';
import '../../../support/fake_publish_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_published_store.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeStudysetGateway gateway;
late FakeCardGateway cardGateway;
late FakeFilePicker files;
setUp(() {
gateway = FakeStudysetGateway();
cardGateway = FakeCardGateway();
files = FakeFilePicker();
});
/// How many syncs ran.
var syncs = 0;
/// Shows the library in a window [width] wide; to a person with an
/// identity ([identified]), whose studysets [publishing] publishes and
/// [publishedStore] remembers as published.
Future<void> showLibrary(
WidgetTester tester, {
double width = 1200,
bool identified = false,
FakePublishGateway? publishing,
MemoryPublishedStore? publishedStore,
}) async {
tester.view.physicalSize = Size(width, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
syncs = 0;
await pumpApp(
tester,
testDependencies(
authGateway: identified
? FakeAuthGateway(
status: const SignedIn(
authUserId: 'github-person',
isIdentified: true,
),
)
: null,
studysetGateway: gateway,
cardGateway: cardGateway,
files: files,
publishGateway: publishing,
publishedStore: publishedStore,
syncOnce: ({required onMerge}) async => syncs++,
),
location: RoutePaths.library,
);
}
/// A studyset on this device before the app starts; a copy of the one
/// published at [publishedAt].
void stored(
String id,
String title, {
String? secondaryTitle,
DateTime? archivedAt,
DateTime? starredAt,
String? publishedAt,
}) => gateway.receiveRemoteChange(
id,
encodeStudysetPayload(
StudysetContent(
title: title,
description: RichContent.empty(),
secondaryTitle: secondaryTitle,
published: publishedAt == null
? null
: const PublishedRef(publication: 'p', item: 'i', version: 1),
publishedAt: publishedAt,
),
),
archivedAt: archivedAt,
starredAt: starredAt,
);
/// [finder] within the card of the studyset titled [title].
Finder onCard(String title, Finder finder) => find.descendant(
of: find.widgetWithText(StudysetCard, title),
matching: finder,
);
/// Chooses [option] in the options of the studyset titled [title].
Future<void> choose(WidgetTester tester, String title, String option) async {
await tester.tap(onCard(title, find.byTooltip('Options')));
await tester.pumpAndSettle();
await tester.tap(find.text(option));
await tester.pumpAndSettle();
}
testWidgets("until this device's first sync went through, an empty "
'library says it is on its way; then that there are no studysets '
'yet', (tester) async {
tester.view.physicalSize = const Size(1200, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final first = Completer<void>();
await pumpApp(
tester,
testDependencies(
studysetGateway: gateway,
syncedBefore: false,
syncOnce: ({required onMerge}) => first.future,
),
);
// Its spinner turns without end: frames, not settling.
await tester.tap(tab('Library'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.textContaining('Your library is on its way from'), findsOne);
expect(find.text('No studysets yet'), findsNothing);
first.complete();
await tester.pumpAndSettle();
expect(find.textContaining('on its way'), findsNothing);
expect(find.text('No studysets yet'), findsOneWidget);
});
testWidgets('says when there are no studysets yet', (tester) async {
await showLibrary(tester);
expect(find.text('No studysets yet'), findsOneWidget);
});
testWidgets('says so when the studysets cannot be read, and reads them '
'again on Retry', (tester) async {
gateway.failReads = Exception('Disk unavailable');
stored('japanese', 'Japanese');
await showLibrary(tester);
expect(find.text("Couldn't read your studysets."), findsOneWidget);
gateway.failReads = null;
await tester.tap(find.widgetWithText(FilledButton, 'Retry'));
await tester.pumpAndSettle();
expect(find.text('Japanese'), findsOneWidget);
});
testWidgets('Refresh syncs, from the menu and by pulling the list '
'down', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
final before = syncs;
await tester.tap(find.byTooltip('Library options'));
await tester.pumpAndSettle();
await tester.tap(find.text('Refresh'));
await tester.pumpAndSettle();
expect(syncs, before + 1);
// Below the cards: a card itself can be dragged elsewhere.
await tester.flingFrom(const Offset(600, 1200), const Offset(0, 400), 1000);
await tester.pumpAndSettle();
expect(syncs, before + 2);
});
testWidgets('an empty library is pulled down to sync too', (tester) async {
await showLibrary(tester);
expect(find.text('No studysets yet'), findsOneWidget);
final before = syncs;
await tester.flingFrom(const Offset(600, 600), const Offset(0, 400), 1000);
await tester.pumpAndSettle();
expect(syncs, before + 1);
});
testWidgets('creates a studyset with a secondary title and a colour', (
tester,
) async {
final semantics = tester.ensureSemantics();
await showLibrary(tester);
await tester.tap(find.text('Create studyset'));
await tester.pumpAndSettle();
expect(find.byType(SheetScaffold), findsOneWidget);
await tester.enterText(
find.widgetWithText(TextField, 'Title'),
'Vocabulary',
);
await tester.enterText(
find.widgetWithText(TextField, 'Secondary title'),
'japanese',
);
await tester.tap(find.bySemanticsLabel('Color: Cobalt'));
await tester.pumpAndSettle();
await tester.tap(find.bySemanticsLabel('Fern'));
await tester.pumpAndSettle();
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
expect(find.text('JAPANESE'), findsOneWidget);
expect(find.text('Vocabulary'), findsOneWidget);
expect(
decodeStudysetPayload(gateway.writes.single.$2),
StudysetContent(
title: 'Vocabulary',
description: RichContent.empty(),
secondaryTitle: 'JAPANESE',
colorName: 'fern',
),
);
semantics.dispose();
});
testWidgets('a new studyset is cobalt unless another colour is chosen', (
tester,
) async {
await showLibrary(tester);
await tester.tap(find.text('Create studyset'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
await tester.pump();
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
expect(
decodeStudysetPayload(gateway.writes.single.$2).colorName,
'cobalt',
);
});
testWidgets('creating waits for a title', (tester) async {
await showLibrary(tester);
await tester.tap(find.text('Create studyset'));
await tester.pumpAndSettle();
FilledButton create() => tester.widget<FilledButton>(
find.ancestor(
of: find.text('Create'),
matching: find.byType(FilledButton),
),
);
expect(create().onPressed, isNull);
// The title is required: said below it, its outline stronger, while
// it is empty.
expect(find.text('Required'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextField, 'Title'), ' ');
await tester.pump();
expect(create().onPressed, isNull);
expect(find.text('Required'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
await tester.pump();
expect(create().onPressed, isNotNull);
expect(find.text('Required'), findsNothing);
});
testWidgets('closing the sheet with changes asks first; staying keeps '
'them, discarding closes it', (tester) async {
await showLibrary(tester);
await tester.tap(find.text('Create studyset'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
await tester.pump();
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(find.text('Discard changes?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.byType(SheetScaffold), findsOneWidget);
expect(find.text('Kanji'), findsOneWidget);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
await tester.tap(find.text('Discard'));
await tester.pumpAndSettle();
expect(find.byType(SheetScaffold), findsNothing);
expect(gateway.writes, isEmpty);
});
testWidgets("the bottom sheet doesn't swipe away, which would lose "
'changes without asking; a tap beside it asks', (tester) async {
await showLibrary(tester, width: 400);
await tester.tap(find.byTooltip('Create studyset'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
await tester.pump();
await tester.fling(
find.text('Create studyset'),
const Offset(0, 600),
2000,
);
await tester.pumpAndSettle();
expect(find.byType(SheetScaffold), findsOneWidget);
expect(find.text('Kanji'), findsOneWidget);
await tester.tapAt(const Offset(200, 20));
await tester.pumpAndSettle();
expect(find.text('Discard changes?'), findsOneWidget);
});
testWidgets('closing an untouched sheet asks nothing', (tester) async {
await showLibrary(tester);
await tester.tap(find.text('Create studyset'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(find.text('Discard changes?'), findsNothing);
expect(find.byType(SheetScaffold), findsNothing);
});
testWidgets('edits a studyset from its options', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await choose(tester, 'Japanese', 'Edit');
expect(find.text('Edit studyset'), findsOneWidget);
await tester.enterText(
find.widgetWithText(TextField, 'Japanese'),
'日本語',
);
await tester.pump();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('日本語'), findsOneWidget);
expect(find.text('Japanese'), findsNothing);
});
testWidgets('lists starred studysets first', (tester) async {
stored('japanese', 'Japanese');
stored('german', 'German', starredAt: DateTime.utc(2026, 10));
await showLibrary(tester);
expect(
tester.getTopLeft(find.text('German')).dx,
lessThan(tester.getTopLeft(find.text('Japanese')).dx),
);
});
testWidgets('archiving tucks a studyset into the closed archive; Undo '
'brings it back', (tester) async {
stored('japanese', 'Japanese');
stored('german', 'German');
await showLibrary(tester);
await choose(tester, 'Japanese', 'Archive');
expect(find.text('Studyset archived'), findsOneWidget);
expect(find.text('Japanese'), findsNothing);
expect(find.text('Archived'), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(find.text('Japanese'), findsOneWidget);
expect(find.text('Archived'), findsNothing);
});
testWidgets('the archive opens to show what rests there, which can be '
'unarchived', (tester) async {
stored('japanese', 'Japanese');
stored('kanji', 'Kanji', archivedAt: DateTime.utc(2026, 10));
await showLibrary(tester);
expect(find.text('Kanji'), findsNothing);
await tester.tap(find.text('Archived'));
await tester.pumpAndSettle();
expect(find.text('Kanji'), findsOneWidget);
await choose(tester, 'Kanji', 'Unarchive');
expect(find.text('Studyset unarchived'), findsOneWidget);
expect(find.text('Archived'), findsNothing);
expect(find.text('Kanji'), findsOneWidget);
});
testWidgets('searches titles and secondary titles, the archive too', (
tester,
) async {
stored('vocabulary', 'Vocabulary', secondaryTitle: 'Japanese');
stored('grammar', 'Grammar');
stored(
'kanji',
'Kanji',
secondaryTitle: 'Japanese',
archivedAt: DateTime.utc(2026, 10),
);
await showLibrary(tester);
await tester.tap(find.byTooltip('Search'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'japan');
await tester.pumpAndSettle();
expect(find.text('Vocabulary'), findsOneWidget);
expect(find.text('Kanji'), findsOneWidget);
expect(find.text('Grammar'), findsNothing);
await tester.tap(find.byTooltip('Close search'));
await tester.pumpAndSettle();
expect(find.text('Grammar'), findsOneWidget);
expect(find.text('Kanji'), findsNothing, reason: 'the archive is closed');
});
testWidgets('says when no studyset matches the search', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await tester.tap(find.byTooltip('Search'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'swahili');
await tester.pumpAndSettle();
expect(find.text('No studysets match your search'), findsOneWidget);
});
testWidgets("the options are every studyset's, as its screen has them, "
'in this order', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await tester.tap(onCard('Japanese', find.byTooltip('Options')));
await tester.pumpAndSettle();
final tops = [
for (final label in [
'Edit',
'Move to…',
'Study options',
'Import content',
'Export content',
'Reset progress',
'Archive',
'Delete',
'More info',
])
tester.getTopLeft(find.text(label)).dy,
];
expect(tops, [...tops]..sort());
});
testWidgets('moves a studyset into another from its options', (
tester,
) async {
stored('japanese', 'Japanese');
stored('languages', 'Languages');
await showLibrary(tester);
await choose(tester, 'Japanese', 'Move to…');
await tester.tap(
find.descendant(
of: find.byType(SheetScaffold),
matching: find.text('Languages'),
),
);
await tester.pump();
await tester.tap(find.text('Move'));
await tester.pumpAndSettle();
expect(gateway.moves.single.$1, 'japanese');
expect(gateway.moves.single.$2, 'languages');
});
testWidgets('Import content opens the studyset, asking there what to '
'import: from Anki, say', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await choose(tester, 'Japanese', 'Import content');
await tester.pumpAndSettle();
expect(
find.descendant(of: find.byType(AppBar), matching: find.text('Japanese')),
findsOneWidget,
);
await tester.tap(find.text('Anki deck'));
await tester.pump();
await tester.pump();
expect(files.asked, [
['apkg', 'colpkg'],
]);
});
group('study options', () {
/// The study options the studyset [id] was last written with.
StudysetStudyOptions? written(String id) => decodeStudysetPayload(
gateway.writes.lastWhere((write) => write.$1 == id).$2,
).studyOptions;
testWidgets('start as the defaults, and are kept with the studyset once '
'saved', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await choose(tester, 'Japanese', 'Study options');
expect(
find.text('20 / day', findRichText: true),
findsOneWidget,
reason: 'new cards a day',
);
expect(find.text('Unlimited'), findsOneWidget, reason: 'reviews a day');
expect(find.text('Reset to defaults'), findsNothing);
await tester.tap(find.byTooltip('Decrease').first);
await tester.pump();
await tester.tap(find.text('Limit reviews per day'));
await tester.pump();
await tester.tap(find.byTooltip('Increase').last);
await tester.pump();
await tester.tap(find.text('Mixed'));
await tester.pump();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(
written('japanese'),
const StudysetStudyOptions(
newPerDay: 15,
reviewsPerDay: 225,
order: NewCardOrder.mixed,
),
);
});
testWidgets('a limit steps to the next multiple of its step, takes any '
'number typed, and is switched off for none, coming back as it '
'was', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await choose(tester, 'Japanese', 'Study options');
Finder newCards() => find.text('20 / day', findRichText: true);
expect(newCards(), findsOneWidget);
await tester.tap(find.byTooltip('Type a number').first);
await tester.pump();
await tester.enterText(find.byType(TextField), '12');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(find.text('12 / day', findRichText: true), findsOneWidget);
await tester.tap(find.byTooltip('Increase').first);
await tester.pump();
expect(find.text('15 / day', findRichText: true), findsOneWidget);
await tester.tap(find.text('Limit new cards per day'));
await tester.pump();
expect(find.text('15 / day', findRichText: true), findsNothing);
expect(find.text('Unlimited'), findsNWidgets(2));
await tester.tap(find.text('Limit new cards per day'));
await tester.pump();
expect(find.text('15 / day', findRichText: true), findsOneWidget);
await tester.tap(find.text('Limit new cards per day'));
await tester.pump();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(written('japanese')?.newPerDay, StudysetStudyOptions.unlimited);
});
testWidgets('saved as they came, the defaults stay the defaults', (
tester,
) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await choose(tester, 'Japanese', 'Study options');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(gateway.writes, isEmpty);
});
testWidgets('go back to the defaults with Reset', (tester) async {
gateway.receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent.titled(
'Japanese',
).withStudyOptions(const StudysetStudyOptions(newPerDay: 5)),
),
);
await showLibrary(tester);
await choose(tester, 'Japanese', 'Study options');
expect(find.text('5 / day', findRichText: true), findsOneWidget);
await tester.tap(find.text('Reset to defaults'));
await tester.pumpAndSettle();
expect(gateway.writes, isNotEmpty);
expect(written('japanese'), isNull);
});
testWidgets('stay when the studyset is edited', (tester) async {
gateway.receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent.titled(
'Japanese',
).withStudyOptions(const StudysetStudyOptions(newPerDay: 5)),
),
);
await showLibrary(tester);
await choose(tester, 'Japanese', 'Edit');
await tester.enterText(
find.widgetWithText(TextField, 'Japanese'),
'Nihongo',
);
await tester.pump();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(written('japanese'), const StudysetStudyOptions(newPerDay: 5));
});
});
testWidgets('Delete stands out in red among the options', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await tester.tap(onCard('Japanese', find.byTooltip('Options')));
await tester.pumpAndSettle();
final error = Theme.of(tester.element(find.text('Delete')))
.colorScheme
.error;
expect(tester.widget<Text>(find.text('Delete')).style?.color, error);
expect(tester.widget<Text>(find.text('Edit')).style?.color, isNot(error));
});
Finder onDeletedCard(String title, Finder finder) => find.descendant(
of: find.widgetWithText(DeletedStudysetCard, title),
matching: finder,
);
testWidgets("deleting doesn't ask: the studyset waits in Recently deleted, "
'saying for how long; Undo brings it back', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await choose(tester, 'Japanese', 'Delete');
expect(find.byType(AlertDialog), findsNothing);
expect(find.text('Moved to Recently deleted'), findsOneWidget);
expect(find.widgetWithText(StudysetCard, 'Japanese'), findsNothing);
expect(find.text('Recently deleted'), findsOneWidget);
expect(
onDeletedCard('Japanese', find.text('14 days left')),
findsOneWidget,
);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(find.widgetWithText(StudysetCard, 'Japanese'), findsOneWidget);
expect(find.text('Recently deleted'), findsNothing);
expect(gateway.deleted, isEmpty);
});
testWidgets('a deleted studyset is restored from Recently deleted, even '
'when it was the only one', (tester) async {
gateway.receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
deletedAt: clock.now().toUtc().subtract(const Duration(days: 10)),
);
await showLibrary(tester);
expect(find.text('No studysets yet'), findsNothing);
expect(
onDeletedCard('Japanese', find.text('4 days left')),
findsOneWidget,
);
await tester.tap(onDeletedCard('Japanese', find.text('Restore')));
await tester.pumpAndSettle();
expect(find.text('Studyset restored'), findsOneWidget);
expect(find.widgetWithText(StudysetCard, 'Japanese'), findsOneWidget);
expect(find.text('Recently deleted'), findsNothing);
});
testWidgets('Delete now asks, then deletes the studyset for good with its '
'cards', (tester) async {
gateway.receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
deletedAt: clock.now().toUtc(),
);
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
final card = await cards.create(
'japanese',
CardContent(front: RichContent.empty(), back: RichContent.empty()),
);
await showLibrary(tester);
Future<void> deleteNow() async {
await tester.tap(onDeletedCard('Japanese', find.byTooltip('Options')));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete now'));
await tester.pumpAndSettle();
expect(find.text('Delete for good?'), findsOneWidget);
}
await deleteNow();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(gateway.deleted, isEmpty);
await deleteNow();
await tester.tap(find.widgetWithText(FilledButton, 'Delete for good'));
await tester.pumpAndSettle();
expect(gateway.deleted, ['japanese']);
expect(cardGateway.deletions, ['blank $card', 'delete $card']);
expect(find.text('Recently deleted'), findsNothing);
expect(find.text('No studysets yet'), findsOneWidget);
});
testWidgets('a card invites studying its due cards, and counts anew after '
'studying', (tester) async {
stored('japanese', 'Japanese');
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
await cards.create(
'japanese',
CardContent(front: RichContent.empty(), back: RichContent.empty()),
);
await showLibrary(tester);
expect(find.text('1 flashcard'), findsOneWidget);
expect(onCard('Japanese', find.text('1')), findsOneWidget);
await tester.tap(find.byTooltip('Study'));
await tester.pumpAndSettle();
await tester.tap(find.text('Show answer'));
await tester.pumpAndSettle();
await tester.tap(find.text('Perfect'));
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.byTooltip('Add content'), findsOneWidget);
expect(onCard('Japanese', find.text('1')), findsNothing);
});
testWidgets('the create button floats in compact windows and sits in the '
'app bar from medium on', (tester) async {
await showLibrary(tester, width: 599);
expect(find.byType(FloatingActionButton), findsOneWidget);
expect(find.text('Create studyset'), findsNothing);
await showLibrary(tester, width: 600);
expect(find.byType(FloatingActionButton), findsNothing);
expect(find.text('Create studyset'), findsOneWidget);
});
group('Who made a studyset, and whether it is public', () {
Publication publication({bool withdrawn = false}) => Publication(
publicId: 'p',
slug: 'japanese',
location: 'halkyon.eu/@raisondetredev/japanese',
withdrawn: withdrawn,
);
testWidgets("the person's own says By you, a copy its author's handle", (
tester,
) async {
stored('japanese', 'Japanese');
stored('kanji', 'Kanji', publishedAt: 'halkyon.eu/@alice/kanji');
await showLibrary(tester);
expect(onCard('Japanese', find.text('By you')), findsOneWidget);
expect(onCard('Kanji', find.text('By @alice')), findsOneWidget);
});
testWidgets('a lock while private, a globe once out on Explore, as the '
'instance says', (tester) async {
stored('japanese', 'Japanese');
stored('kanji', 'Kanji');
stored('kana', 'Kana');
final store = MemoryPublishedStore();
await showLibrary(
tester,
identified: true,
publishing: FakePublishGateway(
publications: {
'japanese': publication(),
'kana': publication(withdrawn: true),
},
),
publishedStore: store,
);
expect(onCard('Japanese', find.byTooltip('Public')), findsOneWidget);
expect(onCard('Kanji', find.byTooltip('Private')), findsOneWidget);
expect(onCard('Kana', find.byTooltip('Private')), findsOneWidget);
// Remembered for when the instance can't be reached.
expect(store.kept, {'japanese'});
});
testWidgets('while the instance is out of reach, as the device last '
'knew', (tester) async {
stored('japanese', 'Japanese');
stored('kanji', 'Kanji');
await showLibrary(
tester,
identified: true,
publishing: FakePublishGateway()..fail = Exception('Offline'),
publishedStore: MemoryPublishedStore({'japanese'}),
);
expect(onCard('Japanese', find.byTooltip('Public')), findsOneWidget);
expect(onCard('Kanji', find.byTooltip('Private')), findsOneWidget);
});
testWidgets('the lock opens the Publish sheet', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await tester.tap(onCard('Japanese', find.byTooltip('Private')));
await tester.pumpAndSettle();
expect(find.text('Publish'), findsOneWidget);
expect(
find.text('Sign in to have a public profile and publish.'),
findsOneWidget,
);
});
});
}

View file

@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
import 'package:material_ui/material_ui.dart';
void main() {
Future<void> show(WidgetTester tester, {required bool enabled}) =>
tester.pumpWidget(
MaterialApp(
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: StudysetBackButton(
ancestors: const [],
all: const [],
enabled: enabled,
),
),
),
);
IconButton button(WidgetTester tester) => tester.widget<IconButton>(
find.descendant(
of: find.byType(StudysetBackButton),
matching: find.byType(IconButton),
),
);
testWidgets('while something runs here, back is greyed and its levels '
'stay closed', (tester) async {
await show(tester, enabled: false);
expect(button(tester).onPressed, isNull);
await tester.longPress(find.byType(StudysetBackButton));
await tester.pumpAndSettle();
expect(find.text('Library'), findsNothing);
});
testWidgets('otherwise back works and holding it lists the levels', (
tester,
) async {
await show(tester, enabled: true);
expect(button(tester).onPressed, isNotNull);
await tester.longPress(find.byType(StudysetBackButton));
await tester.pumpAndSettle();
expect(find.text('Library'), findsOneWidget);
});
}

View file

@ -0,0 +1,265 @@
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
import 'package:halkyon_learn_flutter/src/core/theme/learn_theme.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
import 'package:material_ui/material_ui.dart';
final vocabulary = Studyset(
id: 'vocabulary',
content: StudysetContent(
title: 'Vocabulary',
description: RichContent.empty(),
secondaryTitle: 'Japanese',
colorName: 'cobalt',
),
);
/// 52 cards, a quarter of them mature, none due.
const nothingDue = StudyProgress(cards: 52, due: 0, reviewsDue: 0, mature: 13);
void main() {
late List<String> calls;
setUp(() => calls = []);
Future<void> show(
WidgetTester tester, {
Studyset? studyset,
StudyProgress? progress = nothingDue,
}) async {
await tester.pumpWidget(
MaterialApp(
theme: learnTheme(Brightness.light),
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Center(
child: SizedBox(
width: 360,
height: 188,
child: StudysetCard(
studyset: studyset ?? vocabulary,
progress: progress,
published: false,
onShare: () => calls.add('share'),
onOpen: () => calls.add('open'),
onStudy: () => calls.add('study'),
onToggleStar: () => calls.add('star'),
onOption: (option) => calls.add(option.name),
),
),
),
),
),
);
await tester.pumpAndSettle();
}
Studyset changed({DateTime? archivedAt, DateTime? starredAt}) => Studyset(
id: vocabulary.id,
content: vocabulary.content,
archivedAt: archivedAt,
starredAt: starredAt,
);
testWidgets('shows the title with its kicker and what the studyset '
'holds', (tester) async {
await show(tester);
expect(find.text('JAPANESE'), findsOneWidget);
expect(find.text('Vocabulary'), findsOneWidget);
expect(find.text('52 flashcards'), findsOneWidget);
});
testWidgets('says when a studyset holds nothing yet', (tester) async {
await show(
tester,
progress: const StudyProgress(cards: 0, due: 0, reviewsDue: 0, mature: 0),
);
expect(find.text('No content yet'), findsOneWidget);
});
testWidgets('claims nothing while its progress is being worked out', (
tester,
) async {
await show(tester, progress: null);
expect(find.textContaining('flashcard'), findsNothing);
expect(find.text('No content yet'), findsNothing);
});
testWidgets('invites a review when reviews are due, with how many cards '
'are due', (tester) async {
await show(
tester,
progress: const StudyProgress(
cards: 52,
due: 12,
reviewsDue: 3,
mature: 13,
),
);
expect(find.text('12'), findsOneWidget);
await tester.tap(find.byTooltip('Review'));
expect(calls, ['study']);
});
testWidgets('invites studying when only new cards are due', (tester) async {
await show(
tester,
progress: const StudyProgress(
cards: 52,
due: 20,
reviewsDue: 0,
mature: 0,
),
);
await tester.tap(find.byTooltip('Study'));
expect(calls, ['study']);
});
testWidgets('invites adding content when nothing is due, which opens '
'it', (tester) async {
await show(tester);
expect(find.text('0'), findsNothing);
await tester.tap(find.byTooltip('Add content'));
expect(calls, ['open']);
});
testWidgets('counts at most 99+ due cards', (tester) async {
await show(
tester,
progress: const StudyProgress(
cards: 900,
due: 875,
reviewsDue: 875,
mature: 0,
),
);
expect(find.text('99+'), findsOneWidget);
});
testWidgets('fills its ring with the share of mature cards', (tester) async {
await show(tester);
final ring = tester.widget<CircularProgressIndicator>(
find.byType(CircularProgressIndicator),
);
expect(ring.value, 0.25);
});
testWidgets('an archived studyset rests: dimmed and paused, inviting '
'nothing', (tester) async {
await show(
tester,
studyset: changed(archivedAt: DateTime.utc(2026, 10)),
progress: const StudyProgress(
cards: 52,
due: 12,
reviewsDue: 3,
mature: 13,
),
);
expect(find.byTooltip('Archived'), findsOneWidget);
expect(find.byTooltip('Review'), findsNothing);
expect(find.text('12'), findsNothing);
expect(
tester
.widget<Opacity>(
find.ancestor(
of: find.text('Vocabulary'),
matching: find.byType(Opacity),
),
)
.opacity,
0.6,
);
});
testWidgets('opens when tapped', (tester) async {
await show(tester);
await tester.tap(find.text('Vocabulary'));
expect(calls, ['open']);
});
testWidgets('a starred studyset shows its star, which takes it away', (
tester,
) async {
await show(tester, studyset: changed(starredAt: DateTime.utc(2026, 10)));
await tester.tap(find.byTooltip('Unstar'));
expect(calls, ['star']);
});
testWidgets('shows the star of an unstarred studyset only to a pointer '
'hovering over it', (tester) async {
await show(tester);
expect(find.byTooltip('Star').hitTestable(), findsNothing);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(mouse.removePointer);
await mouse.addPointer(location: tester.getCenter(find.text('Vocabulary')));
await tester.pumpAndSettle();
expect(find.byTooltip('Star').hitTestable(), findsOneWidget);
});
testWidgets('a long press stars it', (tester) async {
await show(tester);
await tester.longPress(find.text('Vocabulary'));
expect(calls, ['star']);
});
for (final (option, call) in [
('Edit', 'edit'),
('Import content', 'importContent'),
('Export content', 'exportContent'),
('Reset progress', 'reset'),
('Archive', 'archive'),
('Delete', 'delete'),
]) {
testWidgets('its options include "$option"', (tester) async {
await show(tester);
await tester.tap(find.byTooltip('Options'));
await tester.pumpAndSettle();
expect(find.text('Unarchive'), findsNothing);
await tester.tap(find.text(option));
await tester.pumpAndSettle();
expect(calls, [call]);
});
}
testWidgets('an archived studyset offers to unarchive it', (tester) async {
await show(tester, studyset: changed(archivedAt: DateTime.utc(2026, 10)));
await tester.tap(find.byTooltip('Options'));
await tester.pumpAndSettle();
expect(find.text('Archive'), findsNothing);
await tester.tap(find.text('Unarchive'));
await tester.pumpAndSettle();
expect(calls, ['unarchive']);
});
}