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>
This commit is contained in:
parent
238a1a93ae
commit
de6af3efab
20 changed files with 2677 additions and 293 deletions
|
|
@ -24,6 +24,41 @@ void main() {
|
|||
};
|
||||
|
||||
group('RichContent', () {
|
||||
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?>[]},
|
||||
|
|
|
|||
|
|
@ -477,13 +477,15 @@ void main() {
|
|||
expect(find.text("Lumo couldn't be opened."), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("the Library's button sits above Create studyset, unfolds into "
|
||||
'the field, and folds back once sent', (tester) async {
|
||||
testWidgets("the Library's button sits at the lower left, Create studyset "
|
||||
'at the lower right, unfolds into the field, and folds back once '
|
||||
'sent', (tester) async {
|
||||
await showApp(tester, location: RoutePaths.library);
|
||||
final button = tester.getCenter(find.byTooltip('Ask or search'));
|
||||
final create = tester.getCenter(find.byTooltip('Create studyset'));
|
||||
expect(button.dy, lessThan(create.dy));
|
||||
expect(button.dx, closeTo(create.dx, 8));
|
||||
expect(button.dx, lessThan(tester.view.physicalSize.width / 4));
|
||||
expect(create.dx, greaterThan(tester.view.physicalSize.width * 3 / 4));
|
||||
expect(button.dy, closeTo(create.dy, 8));
|
||||
|
||||
await unfold(tester);
|
||||
expect(find.byTooltip('Create studyset'), findsNothing);
|
||||
|
|
@ -497,8 +499,9 @@ void main() {
|
|||
testWidgets("a studyset's name goes along only when chosen, and never "
|
||||
'into a web search', (tester) async {
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
// At the lower left, above the studyset's bar.
|
||||
final center = tester.getCenter(find.byTooltip('Ask or search'));
|
||||
expect(center.dx, greaterThan(300));
|
||||
expect(center.dx, lessThan(100));
|
||||
expect(
|
||||
center.dy,
|
||||
lessThan(tester.getCenter(find.byTooltip('Add material')).dy),
|
||||
|
|
@ -518,6 +521,30 @@ void main() {
|
|||
expect(find.text('Studyset: Japanese'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('in a session, the button stands at the lower left, above '
|
||||
'Check, covering nothing that answers the card', (tester) async {
|
||||
final repository = CardRepository(cards);
|
||||
addTearDown(repository.dispose);
|
||||
await repository.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
type: CardType.multipleChoice,
|
||||
front: paragraph('Capital of Japan?'),
|
||||
back: RichContent.empty(),
|
||||
answers: [
|
||||
CardAnswer(content: paragraph('Tokyo'), correct: true),
|
||||
CardAnswer(content: paragraph('Kyoto')),
|
||||
],
|
||||
),
|
||||
);
|
||||
await showApp(tester, location: RoutePaths.study('japanese'));
|
||||
|
||||
final button = tester.getRect(find.byTooltip('Ask or search'));
|
||||
final check = tester.getRect(find.widgetWithText(FilledButton, 'Check'));
|
||||
expect(button.center.dx, lessThan(tester.view.physicalSize.width / 4));
|
||||
expect(button.bottom, lessThan(check.top));
|
||||
});
|
||||
|
||||
testWidgets("in a session, the card's parts go along only as chosen, and "
|
||||
'a chosen hint alone is enough to search', (tester) async {
|
||||
// A desktop: a web search opens Brave Search's results.
|
||||
|
|
|
|||
|
|
@ -5,23 +5,27 @@ import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
|||
|
||||
import '../../../support/fake_auth_gateway.dart';
|
||||
import '../../../support/memory_sign_in_addresses.dart';
|
||||
import '../../../support/memory_signed_out_mark.dart';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway gateway;
|
||||
late MemorySignedOutMark mark;
|
||||
late SessionBloc bloc;
|
||||
|
||||
SessionStateActive anonymous(int number) => SessionStateActive(
|
||||
SignedIn(authUserId: 'anonymous-$number', isIdentified: false),
|
||||
);
|
||||
|
||||
void start({AuthStatus status = const SignedOut()}) {
|
||||
void start({AuthStatus status = const SignedOut(), bool signedOut = false}) {
|
||||
gateway = FakeAuthGateway(status: status);
|
||||
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()));
|
||||
mark = MemorySignedOutMark(signedOut);
|
||||
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()), mark);
|
||||
}
|
||||
|
||||
tearDown(() => bloc.close());
|
||||
|
||||
test('signs in anonymously when nobody is signed in', () async {
|
||||
test('signs in anonymously at a first start, nobody having signed out '
|
||||
'here', () async {
|
||||
start();
|
||||
|
||||
bloc.add(const SessionEventStarted());
|
||||
|
|
@ -61,27 +65,101 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'starts anew with another anonymous account after signing out',
|
||||
() async {
|
||||
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
|
||||
start(status: stored);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
|
||||
test('after signing out, asks how to start, making no account; the next '
|
||||
'start of the app asks again', () async {
|
||||
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
|
||||
start(status: stored);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
|
||||
|
||||
bloc.add(const SessionEventSignOutRequested());
|
||||
bloc.add(const SessionEventSignOutRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([const SessionStateLoading(), anonymous(1)]),
|
||||
);
|
||||
// Signing out of an anonymous account deletes it.
|
||||
expect(gateway.accountDeletions, 1);
|
||||
},
|
||||
);
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const SessionStateLoading(),
|
||||
const SessionStateSignedOut(),
|
||||
]),
|
||||
);
|
||||
// Signing out of an anonymous account deletes it.
|
||||
expect(gateway.accountDeletions, 1);
|
||||
expect(gateway.signIns, 0);
|
||||
expect(mark.signedOut, isTrue);
|
||||
|
||||
test('deleting the account starts anew with another anonymous '
|
||||
'account', () async {
|
||||
await bloc.close();
|
||||
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()), mark)
|
||||
..add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
expect(gateway.signIns, 0);
|
||||
});
|
||||
|
||||
test('starting without an account from there signs in anonymously, and '
|
||||
'the device is no longer signed out', () async {
|
||||
start(signedOut: true);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
|
||||
bloc.add(const SessionEventStartedAnonymously());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([const SessionStateLoading(), anonymous(1)]),
|
||||
);
|
||||
expect(mark.signedOut, isFalse);
|
||||
});
|
||||
|
||||
test('signing in with GitHub from there switches to its person; '
|
||||
'cancelled, it asks again', () async {
|
||||
start(signedOut: true);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
|
||||
bloc.add(const SessionEventGitHubRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(
|
||||
const SessionStateActive(
|
||||
SignedIn(authUserId: 'github-person', isIdentified: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(gateway.signIns, 0);
|
||||
expect(mark.signedOut, isFalse);
|
||||
});
|
||||
|
||||
test('with an email address from there, it starts without an account and '
|
||||
'asks for the address', () async {
|
||||
start(signedOut: true);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
|
||||
bloc.add(const SessionEventEmailRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(
|
||||
const SessionStateActive(
|
||||
SignedIn(authUserId: 'anonymous-1', isIdentified: false),
|
||||
signsInWithEmail: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('a session the server ended asks how to start too', () async {
|
||||
const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
|
||||
start(status: stored);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
|
||||
|
||||
gateway.revokeSession();
|
||||
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
expect(gateway.signIns, 0);
|
||||
});
|
||||
|
||||
test('deleting the account asks how to start', () async {
|
||||
const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
|
||||
start(status: stored);
|
||||
bloc.add(const SessionEventStarted());
|
||||
|
|
@ -91,8 +169,12 @@ void main() {
|
|||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([const SessionStateLoading(), anonymous(1)]),
|
||||
emitsInOrder([
|
||||
const SessionStateLoading(),
|
||||
const SessionStateSignedOut(),
|
||||
]),
|
||||
);
|
||||
expect(gateway.accountDeletions, 1);
|
||||
expect(gateway.signIns, 0);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,25 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets('Settings says the account is anonymous, on which instance; '
|
||||
'one with GitHub, signed in', (tester) async {
|
||||
await openSettings(tester);
|
||||
expect(find.text('Anonymous on learn.example.org'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Back'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Sign in with GitHub'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Settings'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Delete account'),
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
expect(find.text('Signed in on learn.example.org'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'signing out of an anonymous account asks first; Cancel keeps it',
|
||||
(tester) async {
|
||||
|
|
@ -87,8 +106,8 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
testWidgets('a confirmed sign-out starts anew at home with another '
|
||||
'account', (tester) async {
|
||||
testWidgets('a confirmed sign-out asks how to start, making no account; '
|
||||
'starting without one starts anew at home', (tester) async {
|
||||
await openSettings(tester);
|
||||
await tester.tap(find.text('Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -98,14 +117,61 @@ void main() {
|
|||
|
||||
// Signing out of an anonymous account deletes it.
|
||||
expect(gateway.accountDeletions, 1);
|
||||
expect(gateway.status, const SignedOut());
|
||||
expect(find.text('Signed out'), findsOneWidget);
|
||||
expect(find.text('Start on learn.example.org:'), findsOneWidget);
|
||||
expect(find.text('Sign in with GitHub'), findsOneWidget);
|
||||
expect(find.text('Continue with email'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Start without an account'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'anonymous-2', isIdentified: false),
|
||||
);
|
||||
expect(find.text('Settings'), findsNothing);
|
||||
expect(home, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('from there, GitHub signs in its person, making no account '
|
||||
'here', (tester) async {
|
||||
await openSettings(tester);
|
||||
await tester.tap(find.text('Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Sign out').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Sign in with GitHub'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'github-person', isIdentified: true),
|
||||
);
|
||||
expect(home, findsOneWidget);
|
||||
expect(gateway.signIns, 1);
|
||||
});
|
||||
|
||||
testWidgets('from there, an email address starts without an account and '
|
||||
'asks for the address on Profile', (tester) async {
|
||||
await openSettings(tester);
|
||||
await tester.tap(find.text('Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Sign out').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Continue with email'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'anonymous-2', isIdentified: false),
|
||||
);
|
||||
// The email sign-in, over the Profile tab.
|
||||
expect(find.text('Continue with email'), findsWidgets);
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('deleting the account asks first; Cancel keeps it', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
@ -121,8 +187,8 @@ void main() {
|
|||
expect(find.text('Settings'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a confirmed deletion starts anew at home with another '
|
||||
'account', (tester) async {
|
||||
testWidgets('a confirmed deletion asks how to start; starting without an '
|
||||
'account starts anew at home', (tester) async {
|
||||
await openSettings(tester);
|
||||
await tester.tap(find.text('Delete account'));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -131,11 +197,13 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.accountDeletions, 1);
|
||||
expect(find.text('Signed out'), findsOneWidget);
|
||||
await tester.tap(find.text('Start without an account'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'anonymous-2', isIdentified: false),
|
||||
);
|
||||
expect(find.text('Settings'), findsNothing);
|
||||
expect(home, findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -534,19 +534,22 @@ void main() {
|
|||
);
|
||||
|
||||
test("a word's progress comes from its card anywhere in the studyset "
|
||||
'and its topics', () async {
|
||||
'and its topics, the cards read once for which words it holds and '
|
||||
'how far each is learnt', () async {
|
||||
await adder.add('n3', [taberu]);
|
||||
await adder.add('other', [terebi]);
|
||||
final reviews = ReviewRepository(FakeReviewGateway());
|
||||
|
||||
final progress = await EntryProgress(
|
||||
cards,
|
||||
reviews,
|
||||
studysets,
|
||||
).of('vocabulary');
|
||||
final held = await adder.heldIn('vocabulary');
|
||||
final [eat] = await cards.cardsOf('n3');
|
||||
expect(held, {taberu.ref: eat.id});
|
||||
final progress = await EntryProgress(reviews).of(held);
|
||||
|
||||
expect(progress.keys, [taberu.ref]);
|
||||
expect(progress[taberu.ref]?.isNew, isTrue);
|
||||
// A word added since is held with its card, without reading again.
|
||||
await adder.add('vocabulary', [omae]);
|
||||
expect((await adder.heldIn('vocabulary')).keys, contains(omae.ref));
|
||||
});
|
||||
|
||||
test('makes each word a card once, tagged with its level and word '
|
||||
|
|
|
|||
|
|
@ -197,6 +197,94 @@ void main() {
|
|||
expect(picked, ['JP']);
|
||||
});
|
||||
|
||||
testWidgets('a region picked comes to the middle of the map, at the zoom '
|
||||
'it had, as far as the layer still fills it: shown whole, it stays '
|
||||
'put', (tester) async {
|
||||
final picked = await show(tester, around: {'JP-13', 'JP-27'});
|
||||
await choose(tester, 'Kantō');
|
||||
final canvas = tester.getRect(
|
||||
find.descendant(
|
||||
of: find.byType(RegionMap),
|
||||
matching: find.byType(ClipRRect),
|
||||
),
|
||||
);
|
||||
final chiba = atlas.region('JP-12')!.marker;
|
||||
final whole = state(tester).globalOffsetOf(chiba);
|
||||
await tapAt(tester, chiba);
|
||||
expect(picked, ['JP-12']);
|
||||
expect(state(tester).globalOffsetOf(chiba), whole);
|
||||
|
||||
// Zoomed in around Chiba, it stays where it was, off the middle, and
|
||||
// comes to it.
|
||||
final mouse = TestPointer(1, PointerDeviceKind.mouse);
|
||||
await tester.sendEventToBinding(
|
||||
mouse.hover(state(tester).globalOffsetOf(chiba)),
|
||||
);
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendEventToBinding(mouse.scroll(const Offset(0, -200)));
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pumpAndSettle();
|
||||
final zoom = apart(tester, tokyo, chiba);
|
||||
expect(
|
||||
(state(tester).globalOffsetOf(chiba) - canvas.center).distance,
|
||||
greaterThan(20),
|
||||
);
|
||||
|
||||
await tapAt(tester, chiba);
|
||||
|
||||
expect(picked, ['JP-12', 'JP-12']);
|
||||
expect(
|
||||
(state(tester).globalOffsetOf(chiba) - canvas.center).distance,
|
||||
lessThan(2),
|
||||
);
|
||||
expect(apart(tester, tokyo, chiba), closeTo(zoom, 0.5));
|
||||
});
|
||||
|
||||
testWidgets('a map that grows or shrinks keeps where it shows and its zoom, '
|
||||
'never shown whole again', (tester) async {
|
||||
tester.view
|
||||
..physicalSize = const Size(400, 640)
|
||||
..devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
var height = 500.0;
|
||||
late StateSetter resize;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: appTheme(Brightness.light),
|
||||
localizationsDelegates: appLocalizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
resize = setState;
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: RegionMap(atlas: atlas, around: const {'JP-13'}),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final mouse = TestPointer(1, PointerDeviceKind.mouse);
|
||||
await tester.sendEventToBinding(
|
||||
mouse.hover(state(tester).globalOffsetOf(tokyo)),
|
||||
);
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendEventToBinding(mouse.scroll(const Offset(0, -200)));
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pumpAndSettle();
|
||||
const kyoto = (135.77, 35.01);
|
||||
final zoomed = apart(tester, tokyo, kyoto);
|
||||
|
||||
// As a caption below it grows by a line.
|
||||
resize(() => height = 470);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(apart(tester, tokyo, kyoto), closeTo(zoomed, 0.5));
|
||||
});
|
||||
|
||||
testWidgets('a group of one region picks it at once', (tester) async {
|
||||
final picked = await show(tester, around: {'JP-13', 'JP-27'});
|
||||
|
||||
|
|
|
|||
|
|
@ -506,7 +506,18 @@ void main() {
|
|||
});
|
||||
|
||||
testWidgets('the player has play or pause with 10 seconds back and on '
|
||||
'beside it, and where it is from 0:00 to its length', (tester) async {
|
||||
'beside it, each a short tick where the device vibrates, and where it '
|
||||
'is from 0:00 to its length', (tester) async {
|
||||
final ticks = <Object?>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') {
|
||||
ticks.add(call.arguments);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
await watch(tester);
|
||||
await playTo(tester, const Duration(minutes: 1));
|
||||
expect(find.text('1:00'), findsOneWidget);
|
||||
|
|
@ -519,6 +530,7 @@ void main() {
|
|||
await tester.tap(find.byTooltip('10 seconds back'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(players.last!.state.value.position, const Duration(seconds: 50));
|
||||
expect(ticks, List.filled(3, 'HapticFeedbackType.selectionClick'));
|
||||
});
|
||||
|
||||
testWidgets("YouTube's keys work, the lines' too, and ? lists them", (
|
||||
|
|
|
|||
|
|
@ -55,6 +55,23 @@ Map<String, Object?> paragraph(List<Map<String, Object?>> delta) => {
|
|||
};
|
||||
|
||||
void main() {
|
||||
test("a step's first heading repeating its title is left out of its "
|
||||
'text, as the title shows above it', () {
|
||||
final parsed = parsePathImport('''
|
||||
{ "path": { "title": "Kanji", "steps": [
|
||||
{ "title": "What is Kanji?", "type": "info", "content": [
|
||||
{ "type": "heading", "level": 1, "text": "What is Kanji?" },
|
||||
{ "type": "paragraph", "text": "In Japanese…" } ] },
|
||||
{ "title": "Readings", "type": "info", "content": [
|
||||
{ "type": "heading", "level": 1, "text": "On and kun" },
|
||||
{ "type": "paragraph", "text": "Two kinds." } ] }
|
||||
] } }''');
|
||||
|
||||
final [first, second] = parsed.steps;
|
||||
expect(first.content.body.plainText, 'In Japanese…');
|
||||
expect(second.content.body.plainText, 'On and kun\nTwo kinds.');
|
||||
});
|
||||
|
||||
test('reads a path of every kind of step', () {
|
||||
final parsed = parsePathImport(_lesson);
|
||||
|
||||
|
|
|
|||
|
|
@ -754,6 +754,58 @@ void main() {
|
|||
]);
|
||||
});
|
||||
|
||||
testWidgets('a step whose text opens with its own title as a heading '
|
||||
'shows the title once', (tester) async {
|
||||
final paths = repository();
|
||||
final id = await paths.createPath(
|
||||
'japanese',
|
||||
LearningPathContent.titled('Kanji'),
|
||||
);
|
||||
await paths.createStep(
|
||||
id,
|
||||
StepKind.info,
|
||||
StepContent.empty(StepKind.info)
|
||||
.withField(StepField.title, 'What is Kanji?')
|
||||
.withField(
|
||||
StepField.body,
|
||||
RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'heading',
|
||||
'data': {
|
||||
'level': 1,
|
||||
'delta': [
|
||||
{'insert': 'What is Kanji?'},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'In Japanese…'},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await showApp(tester, RoutePaths.path('japanese', id));
|
||||
|
||||
expect(
|
||||
find.textContaining('What is Kanji?', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.textContaining('In Japanese…', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('fills the blanks by tapping words', (tester) async {
|
||||
final paths = repository();
|
||||
final id = await paths.createPath(
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.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/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/round_order.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
|
|
@ -21,6 +23,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
|
|||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_learning_path_gateway.dart';
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
import '../../../support/fake_settings_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
|
|
@ -41,6 +44,7 @@ void main() {
|
|||
late FakeReviewGateway reviewGateway;
|
||||
late ReviewRepository reviews;
|
||||
late TagRepository tags;
|
||||
late LearningPathRepository paths;
|
||||
late SettingsRepository settings;
|
||||
late DateTime now;
|
||||
|
||||
|
|
@ -56,6 +60,7 @@ void main() {
|
|||
reviewGateway = FakeReviewGateway();
|
||||
reviews = ReviewRepository(reviewGateway);
|
||||
tags = TagRepository(FakeTagGateway());
|
||||
paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
settings = SettingsRepository(FakeSettingsGateway());
|
||||
now = DateTime.utc(2026, 10, 5, 9);
|
||||
});
|
||||
|
|
@ -64,6 +69,7 @@ void main() {
|
|||
await studysets.dispose();
|
||||
await cards.dispose();
|
||||
await tags.dispose();
|
||||
await paths.dispose();
|
||||
await settings.dispose();
|
||||
});
|
||||
|
||||
|
|
@ -74,6 +80,7 @@ void main() {
|
|||
deletions,
|
||||
reviews,
|
||||
tags,
|
||||
paths,
|
||||
settings,
|
||||
'japanese',
|
||||
random: math.Random(1),
|
||||
|
|
@ -557,8 +564,60 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
test('a card rated Bad comes again 4 cards on, its pill moving with it; '
|
||||
'one due again within the day goes to the end; one a day or more away '
|
||||
'is done, its pill among those done', () async {
|
||||
for (final front in ['a', 'b', 'c', 'd', 'e', 'f']) {
|
||||
await cards.create('japanese', card(front));
|
||||
}
|
||||
|
||||
await withClock(Clock(() => now), () async {
|
||||
final bloc = session();
|
||||
addTearDown(bloc.close);
|
||||
final a = await showing(bloc, 'a');
|
||||
expect(a.comesBack[Rating.again], const ComesBackIn(4));
|
||||
expect(a.comesBack[Rating.good], const ComesBackAtEnd());
|
||||
// Perfect has a new card due in days: the round is done with it.
|
||||
expect(a.comesBack.containsKey(Rating.easy), isFalse);
|
||||
|
||||
rate(bloc, Rating.again);
|
||||
final b = await showing(bloc, 'b');
|
||||
expect(b.pills.map((pill) => pill.state), [
|
||||
PillState.active,
|
||||
PillState.pending,
|
||||
PillState.pending,
|
||||
PillState.pending,
|
||||
PillState.again,
|
||||
PillState.pending,
|
||||
]);
|
||||
|
||||
rate(bloc, Rating.good);
|
||||
final c = await showing(bloc, 'c');
|
||||
expect(c.pills.map((pill) => pill.state), [
|
||||
PillState.active,
|
||||
PillState.pending,
|
||||
PillState.pending,
|
||||
PillState.again,
|
||||
PillState.pending,
|
||||
PillState.again,
|
||||
]);
|
||||
|
||||
rate(bloc, Rating.easy);
|
||||
final d = await showing(bloc, 'd');
|
||||
expect(d.pills.map((pill) => pill.state), [
|
||||
PillState.done,
|
||||
PillState.active,
|
||||
PillState.pending,
|
||||
PillState.again,
|
||||
PillState.pending,
|
||||
PillState.again,
|
||||
]);
|
||||
expect(d.pills.first.rating, Rating.easy);
|
||||
});
|
||||
});
|
||||
|
||||
test('a card a day or more away is done for the round; one rated Bad '
|
||||
'comes back in it', () async {
|
||||
'comes back in it, at once when it is the last', () async {
|
||||
await cards.create('japanese', card('猫'));
|
||||
await cards.create('japanese', card('犬'));
|
||||
|
||||
|
|
@ -568,12 +627,12 @@ void main() {
|
|||
await showing(bloc, '猫');
|
||||
rate(bloc, Rating.again);
|
||||
final dog = await showing(bloc, '犬');
|
||||
expect(dog.pills.first.state, PillState.again);
|
||||
expect(dog.pills.last.state, PillState.again);
|
||||
rate(bloc, Rating.easy);
|
||||
|
||||
// Nothing else due: the card in its learning steps comes early.
|
||||
final again = await showing(bloc, '猫');
|
||||
expect(again.pills.last.state, PillState.done);
|
||||
expect(again.pills.first.state, PillState.done);
|
||||
expect(again.comesBack[Rating.again], const ComesBackNow());
|
||||
rate(bloc, Rating.easy);
|
||||
|
||||
final done = await until(
|
||||
|
|
@ -1112,6 +1171,7 @@ void main() {
|
|||
deletions,
|
||||
reviews,
|
||||
tags,
|
||||
paths,
|
||||
settings,
|
||||
'japanese',
|
||||
random: math.Random(1),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/round_order.dart';
|
||||
|
||||
void main() {
|
||||
test('Bad brings a card back 4 cards on: at the end with fewer behind '
|
||||
'it, at once with none', () {
|
||||
expect(cardsBefore(Rating.again, 7), 4);
|
||||
expect(comesBack(Rating.again, 7), const ComesBackIn(4));
|
||||
expect(cardsBefore(Rating.again, 4), 4);
|
||||
expect(comesBack(Rating.again, 4), const ComesBackAtEnd());
|
||||
expect(cardsBefore(Rating.again, 2), 2);
|
||||
expect(comesBack(Rating.again, 2), const ComesBackAtEnd());
|
||||
expect(comesBack(Rating.again, 0), const ComesBackNow());
|
||||
});
|
||||
|
||||
test('any other rating that has the card due again within the day brings '
|
||||
'it back at the end', () {
|
||||
for (final rating in [Rating.hard, Rating.good, Rating.easy]) {
|
||||
expect(cardsBefore(rating, 7), 7);
|
||||
expect(comesBack(rating, 7), const ComesBackAtEnd());
|
||||
expect(comesBack(rating, 0), const ComesBackNow());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -131,20 +131,22 @@ void main() {
|
|||
expect(queue, ['child-early', 'parent-late', 'parent-new', 'child-new']);
|
||||
});
|
||||
|
||||
test('each studyset starts its own 20 new cards a local day', () {
|
||||
final parent = [for (var i = 0; i < 25; i++) 'p$i'];
|
||||
test('the studied studyset starts its 20 new cards a local day with '
|
||||
'what is inside it, not 20 each, in tree order', () {
|
||||
final parent = [for (var i = 0; i < 5; i++) 'p$i'];
|
||||
final child = [for (var i = 0; i < 25; i++) 'c$i'];
|
||||
final other = [for (var i = 0; i < 25; i++) 'o$i'];
|
||||
|
||||
final queue = studyQueue(
|
||||
groups: [parent, child],
|
||||
groups: [parent, child, other],
|
||||
schedules: {
|
||||
for (final id in [...parent, ...child]) id: fresh,
|
||||
for (final id in [...parent, ...child, ...other]) id: fresh,
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(queue, [...parent.take(20), ...child.take(20)]);
|
||||
expect(queue, [...parent, ...child.take(15)]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -169,8 +171,8 @@ void main() {
|
|||
['c1', 'c2'],
|
||||
],
|
||||
options: const [
|
||||
StudysetStudyOptions(newPerDay: 6),
|
||||
StudysetStudyOptions(newPerDay: 1),
|
||||
StudysetStudyOptions(newPerDay: StudysetStudyOptions.unlimited),
|
||||
],
|
||||
newPerDay: 1,
|
||||
schedules: const {},
|
||||
|
|
@ -178,7 +180,43 @@ void main() {
|
|||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(queue, ['a1', 'b1', 'b2', 'b3', 'c1'], reason: 'c by default');
|
||||
expect(queue, ['a1', 'a2', 'a3', 'b1', 'c1'], reason: 'c by default');
|
||||
});
|
||||
|
||||
test("the studied studyset's limits hold for everything inside it "
|
||||
'together, counting what was studied today anywhere in it', () {
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
['started', 'a1', 'a2', 'reviewed', 'r1'],
|
||||
['b1', 'b2', 'r2', 'r3', 'learning'],
|
||||
],
|
||||
options: const [
|
||||
StudysetStudyOptions(newPerDay: 2, reviewsPerDay: 3),
|
||||
StudysetStudyOptions(newPerDay: StudysetStudyOptions.unlimited),
|
||||
],
|
||||
schedules: {
|
||||
'started': CardSchedule(
|
||||
reviewCount: 1,
|
||||
due: now.add(const Duration(days: 1)),
|
||||
firstReviewedAt: startOfToday.add(const Duration(hours: 8)),
|
||||
lastReviewedAt: startOfToday.add(const Duration(hours: 8)),
|
||||
),
|
||||
'reviewed': review(
|
||||
now.add(const Duration(days: 3)),
|
||||
lastReviewedAt: startOfToday.add(const Duration(hours: 8)),
|
||||
),
|
||||
'r1': review(overdue),
|
||||
'r2': review(overdue.subtract(const Duration(days: 2))),
|
||||
'r3': review(overdue.subtract(const Duration(days: 1))),
|
||||
'learning': dueAt(overdue, learning: true),
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
// One new card and two reviews left of the day's; the learning card
|
||||
// comes anyway.
|
||||
expect(queue, ['r2', 'r3', 'learning', 'a1']);
|
||||
});
|
||||
|
||||
test('a studyset reviews at most as many cards a day as its options say, '
|
||||
|
|
|
|||
|
|
@ -131,7 +131,9 @@ void main() {
|
|||
]) {
|
||||
expect(find.byIcon(face), findsOneWidget);
|
||||
}
|
||||
expect(find.text('in 1 min'), findsOneWidget);
|
||||
// The only card of the round: Bad, OK and Good bring it again at
|
||||
// once; Perfect has it due in days.
|
||||
expect(find.text('now'), findsNWidgets(3));
|
||||
|
||||
await tester.tap(find.text('Show answer'));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -139,6 +141,21 @@ void main() {
|
|||
expect(find.textContaining('cat', findRichText: true), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("the studyset's name, cut short on a phone, shows whole on a "
|
||||
'long press', (tester) async {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(front: paragraph('猫'), back: paragraph('cat')),
|
||||
);
|
||||
|
||||
await study(tester, width: 360);
|
||||
await tester.longPress(find.text('Japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byTooltip('Japanese'), findsOneWidget);
|
||||
expect(find.text('Japanese'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('a tap on the card shows the answer, and another hides it', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ 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/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
|
|
@ -51,6 +55,8 @@ void main() {
|
|||
late CardRepository cards;
|
||||
late TagRepository tags;
|
||||
late ReviewRepository reviews;
|
||||
late LearningPathRepository paths;
|
||||
late PathProgressRepository pathProgress;
|
||||
late String japanese;
|
||||
late String neko;
|
||||
late String inu;
|
||||
|
|
@ -69,7 +75,8 @@ void main() {
|
|||
cardGateway = _CountingCardGateway();
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(cardGateway);
|
||||
final paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
pathProgress = PathProgressRepository(FakePathAnswerGateway());
|
||||
tags = TagRepository(
|
||||
FakeTagGateway(),
|
||||
cardChanges: cards.changes,
|
||||
|
|
@ -105,7 +112,7 @@ void main() {
|
|||
tags: tags,
|
||||
reviews: reviews,
|
||||
paths: paths,
|
||||
pathProgress: PathProgressRepository(FakePathAnswerGateway()),
|
||||
pathProgress: pathProgress,
|
||||
pdfs: PdfSourceRepository(FakePdfSourceGateway(), assets),
|
||||
studysetId: japanese,
|
||||
);
|
||||
|
|
@ -168,6 +175,57 @@ void main() {
|
|||
}, initialTime: today);
|
||||
});
|
||||
|
||||
test('a learning path tagged with a level offers its plan and counts '
|
||||
'towards it, as cards do; the cards due are counted', () {
|
||||
fakeAsync((async) {
|
||||
start(async, choice: exam);
|
||||
expect(cubit.state.progress?.due, 0);
|
||||
|
||||
run(async, () async {
|
||||
final particles = await paths.createPath(
|
||||
japanese,
|
||||
LearningPathContent.titled('Particles'),
|
||||
);
|
||||
final step = await paths.createStep(
|
||||
particles,
|
||||
StepKind.info,
|
||||
StepContent.empty(StepKind.info),
|
||||
);
|
||||
await paths.createStep(
|
||||
particles,
|
||||
StepKind.info,
|
||||
StepContent.empty(StepKind.info),
|
||||
);
|
||||
await pathProgress.record(step.stepId, AnswerOutcome.continued);
|
||||
await tags.setTagsOfPath(particles, {await tags.create('JLPT-N4')});
|
||||
// An untagged path outside the plan counts for nothing.
|
||||
await paths.createPath(japanese, LearningPathContent.titled('Kanji'));
|
||||
});
|
||||
review(async, neko);
|
||||
async.elapse(ExamCubit.settle);
|
||||
|
||||
expect(
|
||||
cubit.state.plans?.map((plan) => plan.name),
|
||||
['JLPT N5', 'JLPT N4'],
|
||||
);
|
||||
expect(cubit.state.progress?.steps, 0, reason: 'N5 takes no N4 path');
|
||||
|
||||
run(async, () => cubit.choose('auto:jlpt:2', exam.date));
|
||||
async.elapse(ExamCubit.settle);
|
||||
expect(cubit.state.progress?.steps, 2);
|
||||
expect(cubit.state.progress?.stepsDone, 1);
|
||||
|
||||
// Studied once, 猫 is due again three days on; choosing the exam
|
||||
// again reads it anew.
|
||||
async.elapse(const Duration(days: 3));
|
||||
run(async, () => cubit.choose(exam.plan, exam.date));
|
||||
async.elapse(ExamCubit.settle);
|
||||
expect(cubit.state.progress?.due, 1);
|
||||
unawaited(cubit.close());
|
||||
async.flushMicrotasks();
|
||||
}, initialTime: today);
|
||||
});
|
||||
|
||||
test('reads the plans again once a change of tags has settled', () {
|
||||
fakeAsync((async) {
|
||||
start(async, choice: exam);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_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/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/studyset/bloc/material_progress_cubit.dart';
|
||||
|
||||
import '../../../support/fake_asset_gateway.dart';
|
||||
import '../../../support/fake_asset_server_gateway.dart';
|
||||
import '../../../support/fake_learning_path_gateway.dart';
|
||||
import '../../../support/fake_path_answer_gateway.dart';
|
||||
import '../../../support/fake_pdf_source_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
|
||||
void main() {
|
||||
late LearningPathRepository paths;
|
||||
late PathProgressRepository progress;
|
||||
late FakePdfSourceGateway pdfGateway;
|
||||
late PdfSourceRepository pdfs;
|
||||
|
||||
setUp(() {
|
||||
paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
progress = PathProgressRepository(FakePathAnswerGateway());
|
||||
pdfGateway = FakePdfSourceGateway();
|
||||
pdfs = PdfSourceRepository(
|
||||
pdfGateway,
|
||||
AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
),
|
||||
);
|
||||
addTearDown(paths.dispose);
|
||||
addTearDown(progress.dispose);
|
||||
addTearDown(pdfs.dispose);
|
||||
});
|
||||
|
||||
/// A path in [studysetId] with [steps] steps, the first [done] of them
|
||||
/// done.
|
||||
Future<void> path(
|
||||
String studysetId, {
|
||||
required int steps,
|
||||
int done = 0,
|
||||
}) async {
|
||||
final path = await paths.createPath(
|
||||
studysetId,
|
||||
LearningPathContent.titled('Path'),
|
||||
);
|
||||
for (var i = 0; i < steps; i++) {
|
||||
final step = await paths.createStep(
|
||||
path,
|
||||
StepKind.info,
|
||||
StepContent.empty(StepKind.info),
|
||||
);
|
||||
if (i < done) {
|
||||
await progress.record(step.stepId, AnswerOutcome.continued);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF [id] in [studysetId] of [pages] pages, read to [read].
|
||||
Future<void> pdf(
|
||||
String id,
|
||||
String studysetId, {
|
||||
int? pages,
|
||||
int read = 0,
|
||||
}) async {
|
||||
pdfGateway.receiveRemote(
|
||||
id: id,
|
||||
studysetId: studysetId,
|
||||
payload: encodePdfSourcePayload(
|
||||
PdfSourceContent(
|
||||
title: id,
|
||||
fileName: '$id.pdf',
|
||||
assetId: 'asset-$id',
|
||||
pageCount: pages,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (read > 0) {
|
||||
await pdfs.reached(id, read);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the cubit for [studysetIds] reads first, and the cubit.
|
||||
Future<(MaterialProgressState, MaterialProgressCubit)> read(
|
||||
List<String> studysetIds,
|
||||
) async {
|
||||
final cubit = MaterialProgressCubit(paths, progress, pdfs, studysetIds);
|
||||
addTearDown(cubit.close);
|
||||
final first = (await cubit.stream.firstWhere((state) => state != null))!;
|
||||
return (first, cubit);
|
||||
}
|
||||
|
||||
test('counts the steps done and the paths finished across the studyset '
|
||||
'and its topics, and no other studyset', () async {
|
||||
await path('japanese', steps: 3, done: 1);
|
||||
await path('grammar', steps: 2, done: 2);
|
||||
await path('french', steps: 5, done: 5);
|
||||
|
||||
final (progress, _) = await read(['japanese', 'grammar']);
|
||||
|
||||
expect(progress.steps, 5);
|
||||
expect(progress.stepsDone, 3);
|
||||
expect(progress.paths, 2);
|
||||
expect(progress.pathsDone, 1);
|
||||
});
|
||||
|
||||
test('lists the PDFs not read to their last page, those begun first, '
|
||||
'else in the order of the studysets', () async {
|
||||
await pdf('genki', 'japanese', pages: 300);
|
||||
await pdf('done', 'japanese', pages: 10, read: 10);
|
||||
await pdf('tobira', 'grammar', pages: 200, read: 12);
|
||||
// Never opened: how many pages it has is unknown.
|
||||
await pdf('unopened', 'grammar');
|
||||
await pdf('french', 'french', pages: 5, read: 1);
|
||||
|
||||
final (progress, _) = await read(['japanese', 'grammar']);
|
||||
|
||||
expect(progress.pdfs, 4);
|
||||
expect(
|
||||
[
|
||||
for (final (:pdf, :furthestPage) in progress.unfinished)
|
||||
(pdf.id, furthestPage),
|
||||
],
|
||||
[('tobira', 12), ('genki', 0), ('unopened', 0)],
|
||||
);
|
||||
});
|
||||
|
||||
test('reads again as pages are read and steps done', () async {
|
||||
await pdf('genki', 'japanese', pages: 2);
|
||||
await path('japanese', steps: 1);
|
||||
final (_, cubit) = await read(['japanese']);
|
||||
|
||||
await pdfs.reached('genki', 2);
|
||||
await expectLater(
|
||||
cubit.stream,
|
||||
emitsThrough(
|
||||
isA<MaterialProgressState>().having(
|
||||
(p) => p.unfinished,
|
||||
'unfinished',
|
||||
isEmpty,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final step = (await paths.stepsOf(
|
||||
(await paths.pathsOf('japanese')).single.id,
|
||||
)).single;
|
||||
await progress.record(step.id, AnswerOutcome.continued);
|
||||
await expectLater(
|
||||
cubit.stream,
|
||||
emitsThrough(
|
||||
isA<MaterialProgressState>().having(
|
||||
(p) => p.pathsDone,
|
||||
'paths done',
|
||||
1,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/exam_coverage.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
|
||||
void main() {
|
||||
ExamProgress progress({
|
||||
int newCards = 0,
|
||||
int startedToday = 0,
|
||||
int due = 0,
|
||||
}) => (
|
||||
cards: 100,
|
||||
newCards: newCards,
|
||||
startedToday: startedToday,
|
||||
due: due,
|
||||
steps: 0,
|
||||
stepsDone: 0,
|
||||
pages: 0,
|
||||
pagesRead: 0,
|
||||
);
|
||||
|
||||
const ExamPace pace = (
|
||||
daysLeft: 30,
|
||||
newPerDay: 5,
|
||||
stepsPerWeek: 0,
|
||||
pagesPerDay: 0,
|
||||
);
|
||||
|
||||
test('studying for the exam takes every review due and the new cards '
|
||||
'the pace leaves for the day', () {
|
||||
expect(examCardsToStudy(progress(newCards: 40, due: 12), pace), 17);
|
||||
expect(
|
||||
examCardsToStudy(progress(newCards: 40, startedToday: 3, due: 2), pace),
|
||||
4,
|
||||
);
|
||||
expect(
|
||||
examCardsToStudy(progress(newCards: 40, startedToday: 7), pace),
|
||||
0,
|
||||
reason: 'more started today than the pace asks',
|
||||
);
|
||||
expect(
|
||||
examCardsToStudy(progress(newCards: 2, due: 1), pace),
|
||||
3,
|
||||
reason: 'no more new cards than there are',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -152,6 +152,17 @@ void main() {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -21,10 +21,14 @@ import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_runner_screen.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_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_reader_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
|
||||
|
|
@ -45,6 +49,7 @@ import '../../../support/fake_auth_gateway.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_studyset_gateway.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
|
@ -71,9 +76,11 @@ void main() {
|
|||
late FakeCardGateway cardGateway;
|
||||
late FakeLearningPathGateway pathGateway;
|
||||
late FakeNoteGateway noteGateway;
|
||||
late FakePdfSourceGateway pdfGateway;
|
||||
|
||||
setUp(() {
|
||||
noteGateway = FakeNoteGateway();
|
||||
pdfGateway = FakePdfSourceGateway();
|
||||
gateway = FakeStudysetGateway();
|
||||
cardGateway = FakeCardGateway();
|
||||
pathGateway = FakeLearningPathGateway();
|
||||
|
|
@ -98,6 +105,7 @@ void main() {
|
|||
cardGateway: cardGateway,
|
||||
pathGateway: pathGateway,
|
||||
noteGateway: noteGateway,
|
||||
pdfGateway: pdfGateway,
|
||||
)
|
||||
: testDependencies(authGateway: auth),
|
||||
location: location,
|
||||
|
|
@ -1867,6 +1875,12 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(StudysetScreen), findsNothing);
|
||||
expect(find.text('Japanese'), findsNothing);
|
||||
expect(find.text('Signed out'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Start without an account'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Japanese'), findsNothing);
|
||||
expect(find.text('No studysets yet'), findsOneWidget);
|
||||
});
|
||||
|
|
@ -2025,6 +2039,72 @@ void main() {
|
|||
expect(find.text('1 min'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Progress also counts the steps of the learning paths done, '
|
||||
'and lists the PDFs not read to their end, begun first, across the '
|
||||
'topics; one opens where it is read', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('grammar', 'Grammar', parent: 'japanese');
|
||||
final paths = LearningPathRepository(pathGateway);
|
||||
final path = await paths.createPath(
|
||||
'grammar',
|
||||
LearningPathContent.titled('Particles'),
|
||||
);
|
||||
final step = await paths.createStep(
|
||||
path,
|
||||
StepKind.info,
|
||||
StepContent.empty(StepKind.info),
|
||||
);
|
||||
await paths.createStep(
|
||||
path,
|
||||
StepKind.info,
|
||||
StepContent.empty(StepKind.info),
|
||||
);
|
||||
void pdf(String id, String studysetId, String title, int pages) =>
|
||||
pdfGateway.receiveRemote(
|
||||
id: id,
|
||||
studysetId: studysetId,
|
||||
payload: encodePdfSourcePayload(
|
||||
PdfSourceContent(
|
||||
title: title,
|
||||
fileName: '$title.pdf',
|
||||
assetId: 'asset-$id',
|
||||
pageCount: pages,
|
||||
),
|
||||
),
|
||||
);
|
||||
pdf('genki', 'japanese', 'Genki I', 300);
|
||||
pdf('tobira', 'grammar', 'Tobira', 200);
|
||||
pdf('kana', 'grammar', 'Kana chart', 1);
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
final person = PersonScope.of(
|
||||
tester.element(find.byType(StudysetScreen)),
|
||||
);
|
||||
await person.pathProgress.record(step.stepId, AnswerOutcome.continued);
|
||||
await person.pdfSources.reached('tobira', 12);
|
||||
await person.pdfSources.reached('kana', 1);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await openPart(tester, 'Progress');
|
||||
|
||||
expect(find.text('Learning paths'), findsOneWidget);
|
||||
expect(find.text('of 2 steps done'), findsOneWidget);
|
||||
expect(find.text('0 of 1 path finished'), findsOneWidget);
|
||||
expect(find.text('Reading'), findsOneWidget);
|
||||
expect(find.text('Page 12 of 200'), findsOneWidget);
|
||||
expect(find.text('Not begun yet'), findsOneWidget);
|
||||
// Read to its end, it is no longer listed.
|
||||
expect(find.text('Kana chart'), findsNothing);
|
||||
expect(
|
||||
tester.getTopLeft(find.text('Tobira')).dy,
|
||||
lessThan(tester.getTopLeft(find.text('Genki I')).dy),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Tobira'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PdfReaderScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
group('Preparing for an exam', () {
|
||||
testWidgets('a plan offered from level tags is chosen with a date, and '
|
||||
'the study plan shows how far the exam is, what it covers and the '
|
||||
|
|
@ -2060,6 +2140,13 @@ void main() {
|
|||
expect(find.text('0 of 2 flashcards started'), findsOneWidget);
|
||||
expect(find.text('1 new flashcard a day'), findsOneWidget);
|
||||
expect(find.text('Study for the exam'), findsOneWidget);
|
||||
// Next up studies for the exam first: one new card at its pace.
|
||||
final forExam = find.text('Study 1 flashcard for JLPT N5');
|
||||
expect(forExam, findsOneWidget);
|
||||
expect(
|
||||
tester.getTopLeft(forExam).dy,
|
||||
lessThan(tester.getTopLeft(find.text('Study 3 flashcards')).dy),
|
||||
);
|
||||
final japanese = (await person.studysets.all()).singleWhere(
|
||||
(studyset) => studyset.id == 'japanese',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import 'package:halkyon_learn_flutter/src/feature/auth/data/signed_out_mark.dart';
|
||||
|
||||
/// [SignedOutMark] in memory.
|
||||
class MemorySignedOutMark([var bool _signedOut = false])
|
||||
implements SignedOutMark {
|
||||
/// Whether the device is marked signed out now.
|
||||
bool get signedOut => _signedOut;
|
||||
|
||||
@override
|
||||
Future<bool> read() async => _signedOut;
|
||||
|
||||
@override
|
||||
Future<void> write({required bool signedOut}) async => _signedOut = signedOut;
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart
|
|||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/sign_in_addresses.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/signed_out_mark.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
|
|
@ -115,6 +116,7 @@ import 'memory_device_settings_store.dart';
|
|||
import 'memory_pomodoro_run_store.dart';
|
||||
import 'memory_published_store.dart';
|
||||
import 'memory_sign_in_addresses.dart';
|
||||
import 'memory_signed_out_mark.dart';
|
||||
import 'memory_studyset_tab_store.dart';
|
||||
|
||||
/// The app's dependencies with fakes instead of the server and the device's
|
||||
|
|
@ -133,6 +135,7 @@ import 'memory_studyset_tab_store.dart';
|
|||
/// the person asks; never on timers, which a widget test would find pending.
|
||||
Dependencies testDependencies({
|
||||
AuthGateway? authGateway,
|
||||
SignedOutMark? signedOut,
|
||||
SignInAddresses? signInAddresses,
|
||||
StudysetGateway? studysetGateway,
|
||||
CardGateway? cardGateway,
|
||||
|
|
@ -187,6 +190,7 @@ Dependencies testDependencies({
|
|||
authGateway ?? FakeAuthGateway(),
|
||||
signInAddresses ?? MemorySignInAddresses(),
|
||||
),
|
||||
signedOut: signedOut ?? MemorySignedOutMark(),
|
||||
about: AboutRepository(aboutGateway ?? FakeAboutGateway()),
|
||||
dictionaries: DictionaryRepository(
|
||||
dictionaryGateway ?? FakeDictionaryGateway(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue