feat(app): the studyset screen with its topics, the tree's own screen
A studyset's screen, as decided with the tree (2026-09-30/10-01):
- A breadcrumb from the library down to the studyset; its steps go up
(replacing the location, so back leads further up), and steps that
don't fit fold into a "…" listing them, nearest first.
- From expanded windows on, two columns: the description over the topics,
the flashcards beside them, Study and Add material in the app bar. Below
that, the topics are in a drawer, opened from a bar at the bottom that
also holds Study and Add material and slides away while reading onwards;
the back button stays where a drawer would have taken its place.
- The topics: "All topics" for the whole studyset, then the studysets
inside it in their order, each with the study it invites, what it holds
and its options (edit, delete), opening on top of this screen; a switch
rearranges them by dragging; "New sub-studyset" creates one inside.
- Study covers the studyset with everything inside it ("Learn 12" while
something is due). Add material offers flashcards for now.
- Options: Edit (the sheet, now with the description, edited in the whole
window, and for a studyset inside another the colour of the one it is
in, which a new one takes at first), Move to… (the tree and the top
level, without the studyset itself and what is inside it), Archive or
Unarchive with Undo, Delete with Undo, which closes the screen.
- The description cut short when long, the whole a tap away; the licence
as the last line, linking to its text where that is known.
The list's bloc, scoped to a parent, lists its children, works out the
studyset's own progress, creates inside it, reorders and moves. The card
list leaves adding and studying to the screen. The inline editing of the
title and description, and its bloc, are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
9c2960f49d
commit
31d87e8415
5 changed files with 2597 additions and 541 deletions
|
|
@ -1,98 +0,0 @@
|
|||
import 'package:flutter_test/flutter_test.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/studyset/bloc/studyset_edit_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.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 '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway gateway;
|
||||
late StudysetRepository repository;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeStudysetGateway();
|
||||
repository = StudysetRepository(gateway);
|
||||
});
|
||||
tearDown(() => repository.dispose());
|
||||
|
||||
group('StudysetEditBloc', () {
|
||||
late FakeCardGateway cardGateway;
|
||||
late CardRepository cards;
|
||||
late Studyset studyset;
|
||||
late StudysetEditBloc bloc;
|
||||
|
||||
setUp(() async {
|
||||
cardGateway = FakeCardGateway();
|
||||
cards = CardRepository(cardGateway);
|
||||
final id = await repository.create(StudysetContent.titled('Japanese'));
|
||||
studyset = Studyset(id: id, content: StudysetContent.titled('Japanese'));
|
||||
bloc = StudysetEditBloc(repository, cards, studyset);
|
||||
});
|
||||
tearDown(() async {
|
||||
await bloc.close();
|
||||
await cards.dispose();
|
||||
});
|
||||
|
||||
test('deleting the studyset blanks and deletes its cards too', () async {
|
||||
final card = await cards.create(studyset.id, CardContent.empty());
|
||||
|
||||
bloc.add(const StudysetEditEventDeleteRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(
|
||||
isA<StudysetEditState>().having((s) => s.isDeleted, 'deleted', true),
|
||||
),
|
||||
);
|
||||
expect(cardGateway.deletions, ['blank $card', 'delete $card']);
|
||||
expect(await cards.cardsOf(studyset.id), isEmpty);
|
||||
});
|
||||
|
||||
test('keeps changes unsaved until asked to save', () async {
|
||||
bloc.add(const StudysetEditEventTitleChanged('日本語'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(
|
||||
isA<StudysetEditState>().having(
|
||||
(s) => s.hasChanges,
|
||||
'has changes',
|
||||
true,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(await repository.watchAll().first).single.content.title,
|
||||
'Japanese',
|
||||
);
|
||||
|
||||
bloc.add(const StudysetEditEventSaveRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(
|
||||
isA<StudysetEditState>().having(
|
||||
(s) => s.hasChanges,
|
||||
'has changes',
|
||||
false,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect((await repository.watchAll().first).single.content.title, '日本語');
|
||||
});
|
||||
|
||||
test('deletes the studyset and says so', () async {
|
||||
bloc.add(const StudysetEditEventDeleteRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(
|
||||
isA<StudysetEditState>().having((s) => s.isDeleted, 'deleted', true),
|
||||
),
|
||||
);
|
||||
expect(await repository.watchAll().first, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -9,8 +9,8 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletio
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
||||
import '../../../support/eventually.dart';
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
|
|
@ -37,13 +37,17 @@ void main() {
|
|||
});
|
||||
|
||||
/// Opens the library, where a deletion can be undone for [undoWindow].
|
||||
void start({Duration undoWindow = const Duration(milliseconds: 1)}) {
|
||||
void start({
|
||||
Duration undoWindow = const Duration(milliseconds: 1),
|
||||
String? parentId,
|
||||
}) {
|
||||
deletions = StudysetDeletions(studysets, cards, undoWindow: undoWindow);
|
||||
bloc = StudysetListBloc(
|
||||
studysets,
|
||||
cards,
|
||||
ReviewRepository(FakeReviewGateway()),
|
||||
deletions,
|
||||
parentId: parentId,
|
||||
)..add(const StudysetListEventStarted());
|
||||
}
|
||||
|
||||
|
|
@ -245,36 +249,92 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('deleting hides a studyset at once, and deletes it with what is '
|
||||
'inside it once the time to undo is over', () async {
|
||||
final id = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: id,
|
||||
);
|
||||
final card = await cards.create(kana, newCard());
|
||||
start(undoWindow: const Duration(milliseconds: 50));
|
||||
await listing(['Japanese']);
|
||||
|
||||
bloc.add(StudysetListEventDeleteRequested(id));
|
||||
await listing([]);
|
||||
expect(gateway.deleted, isEmpty);
|
||||
|
||||
await eventually(() => gateway.deleted.length == 2);
|
||||
expect(gateway.deleted, [kana, id]);
|
||||
expect(cardGateway.deletions, ['blank $card', 'delete $card']);
|
||||
});
|
||||
|
||||
test('undoing in time keeps the studyset', () async {
|
||||
test('leaves out a studyset whose deletion waits, and shows it again '
|
||||
'when that is undone', () async {
|
||||
final id = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
start(undoWindow: const Duration(hours: 1));
|
||||
await listing(['Japanese']);
|
||||
|
||||
bloc.add(StudysetListEventDeleteRequested(id));
|
||||
deletions.delete(id);
|
||||
await listing([]);
|
||||
bloc.add(StudysetListEventDeleteUndone(id));
|
||||
deletions.undo(id);
|
||||
|
||||
await listing(['Japanese']);
|
||||
expect(gateway.deleted, isEmpty);
|
||||
});
|
||||
|
||||
group('inside a studyset', () {
|
||||
test('lists its children in their order, each with its progress, and '
|
||||
"works out the studyset's own", () async {
|
||||
final japanese = await studysets.create(
|
||||
StudysetContent.titled('Japanese'),
|
||||
);
|
||||
final kanji = await studysets.create(
|
||||
StudysetContent.titled('Kanji'),
|
||||
parentId: japanese,
|
||||
);
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
await studysets.reorder(kana, toIndex: 0);
|
||||
await cards.create(japanese, newCard());
|
||||
await cards.create(kana, newCard());
|
||||
|
||||
start(parentId: japanese);
|
||||
final state = await listing(['Kana', 'Kanji']);
|
||||
|
||||
expect(state.progress, {
|
||||
kana: const StudyProgress(cards: 1, due: 1, reviewsDue: 0, mature: 0),
|
||||
kanji: const StudyProgress(cards: 0, due: 0, reviewsDue: 0, mature: 0),
|
||||
japanese: const StudyProgress(
|
||||
cards: 2,
|
||||
due: 2,
|
||||
reviewsDue: 0,
|
||||
mature: 0,
|
||||
),
|
||||
});
|
||||
expect(state.studysets.map((studyset) => studyset.id), [kana, kanji]);
|
||||
});
|
||||
|
||||
test('moves a studyset somewhere else', () async {
|
||||
final japanese = await studysets.create(
|
||||
StudysetContent.titled('Japanese'),
|
||||
);
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
start(parentId: japanese);
|
||||
await listing(['Kana']);
|
||||
|
||||
bloc.add(StudysetListEventMoveRequested(kana, parentId: null));
|
||||
|
||||
await listing([]);
|
||||
expect(topLevel(await studysets.all()), hasLength(2));
|
||||
});
|
||||
|
||||
test('creates studysets inside it and reorders them', () async {
|
||||
final japanese = await studysets.create(
|
||||
StudysetContent.titled('Japanese'),
|
||||
);
|
||||
start(parentId: japanese);
|
||||
await listing([]);
|
||||
|
||||
bloc
|
||||
..add(StudysetListEventCreateRequested(StudysetContent.titled('Kana')))
|
||||
..add(
|
||||
StudysetListEventCreateRequested(StudysetContent.titled('Kanji')),
|
||||
);
|
||||
final created = await listing(['Kana', 'Kanji']);
|
||||
bloc.add(
|
||||
StudysetListEventReorderRequested(
|
||||
created.studysets.last.id,
|
||||
toIndex: 0,
|
||||
),
|
||||
);
|
||||
|
||||
await listing(['Kanji', 'Kana']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license_link.dart';
|
||||
|
||||
void main() {
|
||||
test('links a Creative Commons licence to its deed', () {
|
||||
expect(
|
||||
licenseUrl('CC-BY-SA-4.0'),
|
||||
Uri.parse('https://creativecommons.org/licenses/by-sa/4.0/'),
|
||||
);
|
||||
expect(
|
||||
licenseUrl('CC-BY-NC-SA-3.0-US'),
|
||||
Uri.parse('https://creativecommons.org/licenses/by-nc-sa/3.0/us/'),
|
||||
);
|
||||
expect(
|
||||
licenseUrl('CC0-1.0'),
|
||||
Uri.parse('https://creativecommons.org/publicdomain/zero/1.0/'),
|
||||
);
|
||||
});
|
||||
|
||||
test("links other well-known licences to SPDX's page", () {
|
||||
expect(
|
||||
licenseUrl('MIT'),
|
||||
Uri.parse('https://spdx.org/licenses/MIT.html'),
|
||||
);
|
||||
expect(
|
||||
licenseUrl('AGPL-3.0-or-later'),
|
||||
Uri.parse('https://spdx.org/licenses/AGPL-3.0-or-later.html'),
|
||||
);
|
||||
});
|
||||
|
||||
test('links nothing it does not know', () {
|
||||
expect(licenseUrl('All Rights Reserved'), isNull);
|
||||
expect(licenseUrl('My own licence'), isNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
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/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
|
@ -148,4 +149,28 @@ void main() {
|
|||
expect(studiedIn('kanji', all), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('a studyset without a colour of its own takes the nearest one above '
|
||||
'it', () {
|
||||
Studyset coloured(String id, String? color, {String? parent}) => Studyset(
|
||||
id: id,
|
||||
content: StudysetContent(
|
||||
title: id,
|
||||
description: RichContent.empty(),
|
||||
colorName: color,
|
||||
),
|
||||
parentId: parent,
|
||||
);
|
||||
final all = [
|
||||
coloured('japanese', 'fern'),
|
||||
coloured('kana', null, parent: 'japanese'),
|
||||
coloured('hiragana', null, parent: 'kana'),
|
||||
coloured('kanji', 'plum', parent: 'japanese'),
|
||||
coloured('german', null),
|
||||
];
|
||||
|
||||
expect(colorNameOf('hiragana', all), 'fern');
|
||||
expect(colorNameOf('kanji', all), 'plum');
|
||||
expect(colorNameOf('german', all), isNull);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +1,369 @@
|
|||
import 'package:flutter_test/flutter_test.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/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/studyset_breadcrumb.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../support/fake_auth_gateway.dart';
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
/// Rich content with one paragraph of [text].
|
||||
RichContent paragraph(String text) => RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': text},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway gateway;
|
||||
late FakeCardGateway cardGateway;
|
||||
|
||||
/// Shows the app at [location], the library unless given; every person
|
||||
/// shares [gateway] unless [auth] is given, in which case each person gets
|
||||
/// data of their own.
|
||||
setUp(() {
|
||||
gateway = FakeStudysetGateway();
|
||||
cardGateway = FakeCardGateway();
|
||||
});
|
||||
|
||||
/// Shows the app at [location] in a window [width] wide. Every person
|
||||
/// shares the gateways unless [auth] is given; then each gets their own.
|
||||
Future<void> showApp(
|
||||
WidgetTester tester, {
|
||||
FakeAuthGateway? auth,
|
||||
String location = RoutePaths.library,
|
||||
double width = 1200,
|
||||
FakeAuthGateway? auth,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1200, 1600);
|
||||
tester.view.physicalSize = Size(width, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await pumpApp(
|
||||
tester,
|
||||
auth == null
|
||||
? testDependencies(studysetGateway: gateway)
|
||||
? testDependencies(studysetGateway: gateway, cardGateway: cardGateway)
|
||||
: testDependencies(authGateway: auth),
|
||||
location: location,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a studyset titled [title] in the library's sheet.
|
||||
Future<void> create(WidgetTester tester, String title) async {
|
||||
await tester.tap(find.text('New studyset'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Title'), title);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Create'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> open(WidgetTester tester, String title) async {
|
||||
await tester.tap(find.text(title));
|
||||
await tester.pump();
|
||||
// Flushes the editor's timers.
|
||||
// Flushes the description view's timers.
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
setUp(() => gateway = FakeStudysetGateway());
|
||||
/// A studyset on this device before the app starts.
|
||||
void stored(
|
||||
String id,
|
||||
String title, {
|
||||
String? parent,
|
||||
String position = 'a0',
|
||||
RichContent? description,
|
||||
String? license,
|
||||
String? licenseHolder,
|
||||
int? licenseYear,
|
||||
}) => gateway.receiveRemoteChange(
|
||||
id,
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: title,
|
||||
description: description ?? RichContent.empty(),
|
||||
license: license,
|
||||
licenseHolder: licenseHolder,
|
||||
licenseYear: licenseYear,
|
||||
),
|
||||
),
|
||||
parentId: parent,
|
||||
position: position,
|
||||
);
|
||||
|
||||
testWidgets('saves a changed title', (tester) async {
|
||||
/// Opens the studyset titled [title] from the library, so its screen has
|
||||
/// the library below it.
|
||||
Future<void> openFromLibrary(WidgetTester tester, String title) async {
|
||||
await showApp(tester);
|
||||
await create(tester, 'Japanese');
|
||||
await open(tester, 'Japanese');
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Japanese'),
|
||||
'日本語',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Save'));
|
||||
await tester.pump();
|
||||
await tester.pageBack();
|
||||
await tester.tap(find.text(title));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
expect(find.text('日本語'), findsOneWidget);
|
||||
/// Chooses [option] in the studyset's options in the app bar.
|
||||
Future<void> choose(WidgetTester tester, String option) async {
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AppBar),
|
||||
matching: find.byTooltip('Options'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(option));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<String> card(String studysetId, String front) {
|
||||
final cards = CardRepository(cardGateway);
|
||||
addTearDown(cards.dispose);
|
||||
return cards.create(
|
||||
studysetId,
|
||||
CardContent(front: paragraph(front), back: paragraph('back')),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('shows where a studyset sits, what it is about, its topics and '
|
||||
'its flashcards', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored(
|
||||
'kana',
|
||||
'Kana',
|
||||
parent: 'japanese',
|
||||
description: paragraph('The two syllabaries.'),
|
||||
);
|
||||
stored('hiragana', 'Hiragana', parent: 'kana');
|
||||
|
||||
await showApp(tester, location: RoutePaths.studyset('kana'));
|
||||
|
||||
Finder step(String text) => find.descendant(
|
||||
of: find.byType(StudysetBreadcrumb),
|
||||
matching: find.text(text),
|
||||
);
|
||||
expect(step('Library'), findsOneWidget);
|
||||
expect(step('Japanese'), findsOneWidget);
|
||||
expect(step('Kana'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('The two syllabaries.', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Topics'), findsOneWidget);
|
||||
expect(find.text('Hiragana'), findsOneWidget);
|
||||
expect(find.text('All topics'), findsOneWidget);
|
||||
expect(find.text('Flashcards'), findsOneWidget);
|
||||
expect(find.text('No flashcards yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('deleting asks first; Cancel keeps the studyset', (tester) async {
|
||||
await showApp(tester);
|
||||
await create(tester, 'Japanese');
|
||||
await open(tester, 'Japanese');
|
||||
testWidgets('a step of the way leads up there, and back leads further '
|
||||
'up', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('kana', 'Kana', parent: 'japanese');
|
||||
stored('hiragana', 'Hiragana', parent: 'kana');
|
||||
await showApp(tester, location: RoutePaths.studyset('hiragana'));
|
||||
|
||||
await tester.tap(find.byTooltip('Delete'));
|
||||
await tester.tap(find.text('Japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Delete this studyset?'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
expect(
|
||||
find.descendant(of: find.byType(AppBar), matching: find.text('Japanese')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(StudysetScreen), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('creates a topic, which takes the colour of the studyset it '
|
||||
'is in and opens on top of it', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
expect(find.text('No sub-studysets yet.'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('New sub-studyset'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kana');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Create'));
|
||||
await tester.pumpAndSettle();
|
||||
final (id, payload) = gateway.writes.last;
|
||||
expect(decodeStudysetPayload(payload).colorName, isNull);
|
||||
|
||||
await tester.tap(find.text('Kana'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.descendant(of: find.byType(AppBar), matching: find.text('Kana')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(id, isNotEmpty);
|
||||
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.descendant(of: find.byType(AppBar), matching: find.text('Japanese')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('studies everything inside a studyset', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('kana', 'Kana', parent: 'japanese');
|
||||
await card('kana', 'あ');
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
|
||||
await tester.tap(find.text('Learn 1'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('あ', findRichText: true), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('moves a studyset into another', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('kana', 'Kana');
|
||||
await openFromLibrary(tester, 'Kana');
|
||||
|
||||
await choose(tester, 'Move to…');
|
||||
expect(find.text('Move studyset to'), findsOneWidget);
|
||||
await tester.tap(find.text('Japanese'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Move'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Moved to Japanese.'), findsOneWidget);
|
||||
expect(gateway.moves.single.$2, 'japanese');
|
||||
expect(find.text('Japanese'), findsOneWidget, reason: 'a step of the way');
|
||||
});
|
||||
|
||||
testWidgets("can't move a studyset into itself or anything inside it", (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('kana', 'Kana', parent: 'japanese');
|
||||
stored('german', 'German');
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
await choose(tester, 'Move to…');
|
||||
|
||||
await tester.tap(find.byTooltip('Show what is inside'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
bool enabled(String title) => tester
|
||||
.widget<ListTile>(find.widgetWithText(ListTile, title).last)
|
||||
.enabled;
|
||||
expect(enabled('Japanese'), isFalse);
|
||||
expect(enabled('Kana'), isFalse);
|
||||
expect(enabled('German'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('deleting from its own screen asks, closes it and offers to '
|
||||
'undo; then it goes with everything inside it', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('kana', 'Kana', parent: 'japanese');
|
||||
final kanaCard = await card('kana', 'あ');
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
|
||||
await choose(tester, 'Delete');
|
||||
expect(find.text('Delete studyset?'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(StudysetScreen), findsNothing);
|
||||
expect(find.text('Studyset deleted'), findsOneWidget);
|
||||
expect(find.text('No studysets yet'), findsOneWidget);
|
||||
expect(gateway.deleted, isEmpty);
|
||||
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.deleted, ['kana', 'japanese']);
|
||||
expect(cardGateway.deletions, ['blank $kanaCard', 'delete $kanaCard']);
|
||||
});
|
||||
|
||||
testWidgets('Undo after deleting from its own screen keeps it', (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
await choose(tester, 'Delete');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Undo'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Japanese'), findsOneWidget);
|
||||
expect(gateway.deleted, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('edits the studyset in the sheet, the description in the '
|
||||
'whole window', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
|
||||
await choose(tester, 'Edit');
|
||||
expect(find.text('Edit studyset'), findsOneWidget);
|
||||
await tester.tap(find.text('Add a description'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(find.text('Edit description'), findsOneWidget);
|
||||
await tester.tap(find.text('Save').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Japanese'), '日本語');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.descendant(of: find.byType(AppBar), matching: find.text('日本語')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('below expanded windows the topics are in a drawer, opened '
|
||||
'from the bar at the bottom, and the back button stays', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('kana', 'Kana', parent: 'japanese');
|
||||
tester.view.physicalSize = const Size(600, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(studysetGateway: gateway, cardGateway: cardGateway),
|
||||
location: RoutePaths.library,
|
||||
);
|
||||
await tester.tap(find.text('Japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Back'), findsOneWidget);
|
||||
expect(find.text('Kana'), findsNothing);
|
||||
|
||||
await tester.tap(find.byTooltip('Show topics'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Topics'), findsOneWidget);
|
||||
expect(find.text('Kana'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows its licence', (tester) async {
|
||||
stored(
|
||||
'grammar',
|
||||
'Grammar',
|
||||
license: 'CC-BY-SA-4.0',
|
||||
licenseHolder: 'Tae Kim',
|
||||
licenseYear: 2006,
|
||||
);
|
||||
|
||||
await showApp(tester, location: RoutePaths.studyset('grammar'));
|
||||
|
||||
expect(find.text('© 2006 Tae Kim'), findsOneWidget);
|
||||
expect(find.text('CC-BY-SA-4.0 ↗'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an open studyset closes when the person changes, and the next '
|
||||
'person sees none of it', (tester) async {
|
||||
final auth = FakeAuthGateway();
|
||||
await showApp(tester, auth: auth);
|
||||
await create(tester, 'Japanese');
|
||||
await open(tester, 'Japanese');
|
||||
await tester.tap(find.text('New studyset'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Japanese');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Create'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(StudysetScreen), findsOneWidget);
|
||||
|
||||
auth.revokeSession();
|
||||
|
|
@ -99,32 +374,13 @@ void main() {
|
|||
expect(find.text('No studysets yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a confirmed delete removes the studyset and closes it, '
|
||||
'without it showing as gone on its way out', (tester) async {
|
||||
await showApp(tester);
|
||||
await create(tester, 'Japanese');
|
||||
await open(tester, 'Japanese');
|
||||
|
||||
await tester.tap(find.byTooltip('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(find.textContaining('no longer exists'), findsNothing);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.deleted, hasLength(1));
|
||||
expect(find.text('No studysets yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an open studyset deleted on another device says so', (
|
||||
tester,
|
||||
) async {
|
||||
await showApp(tester);
|
||||
await create(tester, 'Japanese');
|
||||
await open(tester, 'Japanese');
|
||||
stored('japanese', 'Japanese');
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
|
||||
gateway.receiveRemoteDeletion('studyset-1');
|
||||
gateway.receiveRemoteDeletion('japanese');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(StudysetScreen), findsNothing);
|
||||
|
|
|
|||
Loading…
Reference in a new issue