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:
parent
11abd9afce
commit
649499be23
3 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,119 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
|
||||
|
||||
import '../../../support/fake_settings_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late FakeSettingsGateway gateway;
|
||||
late SettingsRepository repository;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeSettingsGateway();
|
||||
repository = SettingsRepository(gateway);
|
||||
});
|
||||
tearDown(() => repository.dispose());
|
||||
|
||||
const verb = (id: 'tag-1', name: 'verb');
|
||||
const noun = (id: 'tag-2', name: 'noun');
|
||||
|
||||
test('a person without settings has the defaults, and nothing is '
|
||||
'stored for reading them', () async {
|
||||
expect(await repository.read(), const PersonSettings());
|
||||
expect(gateway.writes, isEmpty);
|
||||
});
|
||||
|
||||
test('stores a change in one row, made by the first change', () async {
|
||||
await repository.update((s) => s.withStudyTags('japanese', [verb]));
|
||||
await repository.update((s) => s.withStudyTags('german', [noun]));
|
||||
|
||||
expect(
|
||||
await repository.read(),
|
||||
const PersonSettings(
|
||||
studyTags: {
|
||||
'japanese': [verb],
|
||||
'german': [noun],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect({for (final (id, _) in gateway.writes) id}, hasLength(1));
|
||||
});
|
||||
|
||||
test('two rows from two devices fold into the one with the smaller id, '
|
||||
"with the other's settings added where it has none", () async {
|
||||
await repository.update((s) => s.withStudyTags('japanese', [verb]));
|
||||
gateway.receiveRemoteRow(
|
||||
'settings-000',
|
||||
encodeSettingsPayload(
|
||||
const PersonSettings(
|
||||
studyTags: {
|
||||
'japanese': [noun],
|
||||
'german': [noun],
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final settings = await repository.read();
|
||||
|
||||
expect(
|
||||
settings,
|
||||
const PersonSettings(
|
||||
studyTags: {
|
||||
'japanese': [noun],
|
||||
'german': [noun],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(gateway.deleted, ['settings-001']);
|
||||
// Blanked before deleted.
|
||||
expect(gateway.writes.last, ('settings-001', redactedPayload));
|
||||
});
|
||||
|
||||
test('leaves settings of a newer app version alone, and keeps its own '
|
||||
'beside them', () async {
|
||||
gateway.receiveRemoteRow(
|
||||
'settings-000',
|
||||
encodePayload(settingsSchemaVersion + 1, {'studyTags': {}}),
|
||||
);
|
||||
|
||||
await repository.update((s) => s.withStudyTags('japanese', [verb]));
|
||||
|
||||
expect(
|
||||
(await repository.read()).studyTagsOf('japanese'),
|
||||
[verb],
|
||||
);
|
||||
expect(gateway.deleted, isEmpty);
|
||||
expect(gateway.writes.single.$1, isNot('settings-000'));
|
||||
});
|
||||
|
||||
test('follows changes on this device and from others', () async {
|
||||
final watched = repository.watch();
|
||||
final expectation = expectLater(
|
||||
watched.map((s) => s.studyTags.keys.toList()),
|
||||
emitsInOrder([
|
||||
<String>[],
|
||||
['japanese'],
|
||||
['japanese', 'german'],
|
||||
]),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await repository.update((s) => s.withStudyTags('japanese', [verb]));
|
||||
final id = gateway.writes.single.$1;
|
||||
gateway.receiveRemoteRow(
|
||||
id,
|
||||
encodeSettingsPayload(
|
||||
const PersonSettings(
|
||||
studyTags: {
|
||||
'japanese': [verb],
|
||||
'german': [noun],
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await expectation;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,480 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
|
||||
void main() {
|
||||
const verb = (id: 'tag-1', name: 'verb');
|
||||
const noun = (id: 'tag-2', name: 'noun');
|
||||
|
||||
test('a studyset studied by no tags is left out', () {
|
||||
final settings = const PersonSettings()
|
||||
.withStudyTags('japanese', [verb])
|
||||
.withStudyTags('japanese', []);
|
||||
|
||||
expect(settings.studyTags, isEmpty);
|
||||
expect(settings.studyTagsOf('japanese'), isEmpty);
|
||||
});
|
||||
|
||||
test('folding keeps these settings and adds what only the other has', () {
|
||||
const mine = PersonSettings(
|
||||
studyTags: {
|
||||
'japanese': [verb],
|
||||
},
|
||||
);
|
||||
const theirs = PersonSettings(
|
||||
studyTags: {
|
||||
'japanese': [noun],
|
||||
'german': [noun],
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
mine.foldedWith(theirs),
|
||||
const PersonSettings(
|
||||
studyTags: {
|
||||
'japanese': [verb],
|
||||
'german': [noun],
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('survive a round trip through the payload', () {
|
||||
const settings = PersonSettings(
|
||||
studyTags: {
|
||||
'japanese': [verb, noun],
|
||||
'日本語': [noun],
|
||||
},
|
||||
);
|
||||
|
||||
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||
});
|
||||
|
||||
test('study options survive a round trip, and a payload of the first '
|
||||
'schema reads with them as they come', () {
|
||||
const settings = PersonSettings(
|
||||
study: StudyOptions(
|
||||
mode: LearnMode.cram,
|
||||
order: StudyOrder.random,
|
||||
cardsPerRound: 10,
|
||||
newPerDay: 0,
|
||||
backFirst: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||
expect(
|
||||
decodeSettingsPayload(encodePayload(1, {'studyTags': {}})).study,
|
||||
const StudyOptions(),
|
||||
);
|
||||
});
|
||||
|
||||
test('quizzing vocabulary as multiple choice survives a round trip, in the '
|
||||
'fifth schema; an aspect of a newer app reads as the meaning', () {
|
||||
const settings = PersonSettings(
|
||||
study: StudyOptions(
|
||||
quizVocabulary: true,
|
||||
quizAspect: VocabularyQuizAspect.mixed,
|
||||
),
|
||||
);
|
||||
|
||||
expect(settingsSchemaVersion, greaterThanOrEqualTo(5));
|
||||
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||
expect(
|
||||
decodeSettingsPayload(
|
||||
encodePayload(5, {
|
||||
'studyTags': {},
|
||||
'study': {'quizVocabulary': true, 'quizAspect': 'kanji'},
|
||||
}),
|
||||
).study,
|
||||
const StudyOptions(quizVocabulary: true),
|
||||
);
|
||||
});
|
||||
|
||||
test('typing readings survives a round trip, in the sixth schema; a '
|
||||
'payload from before has it off', () {
|
||||
const settings = PersonSettings(study: StudyOptions(typeReadings: true));
|
||||
|
||||
expect(settingsSchemaVersion, greaterThanOrEqualTo(6));
|
||||
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||
expect(
|
||||
decodeSettingsPayload(
|
||||
encodePayload(5, {'studyTags': {}, 'study': {}}),
|
||||
).study.typeReadings,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('writing by hand survives a round trip, in the seventh schema; a '
|
||||
'payload from before has it off', () {
|
||||
const settings = PersonSettings(study: StudyOptions(writeByHand: true));
|
||||
|
||||
expect(settingsSchemaVersion, greaterThanOrEqualTo(7));
|
||||
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||
expect(
|
||||
decodeSettingsPayload(
|
||||
encodePayload(6, {'studyTags': {}, 'study': {}}),
|
||||
).study.writeByHand,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('study options keep their numbers in range', () {
|
||||
final options = const StudyOptions().copyWith(
|
||||
cardsPerRound: 50,
|
||||
newPerDay: -5,
|
||||
);
|
||||
|
||||
expect(options.cardsPerRound, StudyOptions.maxCardsPerRound);
|
||||
expect(options.newPerDay, StudyOptions.unlimitedNewPerDay);
|
||||
});
|
||||
|
||||
test('newest first is for cramming; spaced repetition goes oldest '
|
||||
'first', () {
|
||||
expect(
|
||||
const StudyOptions(order: StudyOrder.newestFirst).effectiveOrder,
|
||||
StudyOrder.oldestFirst,
|
||||
);
|
||||
expect(
|
||||
const StudyOptions(
|
||||
mode: LearnMode.cram,
|
||||
order: StudyOrder.newestFirst,
|
||||
).effectiveOrder,
|
||||
StudyOrder.newestFirst,
|
||||
);
|
||||
});
|
||||
|
||||
test('the goal, rest days and home layout survive a round trip', () {
|
||||
final settings = PersonSettings(
|
||||
goal: const StudyGoal(period: GoalPeriod.week, target: 150),
|
||||
restDays: {DateTime(2026, 10, 5), DateTime(2026, 12, 24)},
|
||||
home: const HomeLayout(
|
||||
order: [
|
||||
HomeWidget.activity,
|
||||
HomeWidget.goal,
|
||||
HomeWidget.pomodoro,
|
||||
HomeWidget.deadlines,
|
||||
HomeWidget.week,
|
||||
],
|
||||
hidden: {HomeWidget.week},
|
||||
),
|
||||
);
|
||||
|
||||
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||
});
|
||||
|
||||
test('a home layout from another version keeps the widgets this one '
|
||||
'knows, and adds those it misses', () {
|
||||
final settings = decodeSettingsPayload(
|
||||
encodePayload(3, {
|
||||
'studyTags': {},
|
||||
'home': {
|
||||
'order': ['activity', 'clock'],
|
||||
'hidden': ['clock'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(settings.home.order, [
|
||||
HomeWidget.activity,
|
||||
HomeWidget.goal,
|
||||
HomeWidget.pomodoro,
|
||||
HomeWidget.deadlines,
|
||||
HomeWidget.week,
|
||||
]);
|
||||
expect(settings.home.hidden, isEmpty);
|
||||
});
|
||||
|
||||
test('folding keeps both rest days, and the goal one of them has', () {
|
||||
final mine = PersonSettings(restDays: {DateTime(2026, 10, 5)});
|
||||
final theirs = PersonSettings(
|
||||
goal: const StudyGoal(period: GoalPeriod.day, target: 20),
|
||||
restDays: {DateTime(2026, 10, 6)},
|
||||
);
|
||||
|
||||
final folded = mine.foldedWith(theirs);
|
||||
|
||||
expect(folded.restDays, {DateTime(2026, 10, 5), DateTime(2026, 10, 6)});
|
||||
expect(folded.goal, theirs.goal);
|
||||
});
|
||||
|
||||
group('Pomodoro profiles', () {
|
||||
const mine = PomodoroProfile(
|
||||
id: 'mine',
|
||||
name: 'Exam weeks',
|
||||
focusMinutes: 40,
|
||||
shortBreakMinutes: 8,
|
||||
longBreakMinutes: 20,
|
||||
longBreakAfter: 3,
|
||||
autoStartFocus: true,
|
||||
autoStartBreaks: true,
|
||||
doNotDisturb: true,
|
||||
);
|
||||
|
||||
test('survive a round trip, in the newest schema', () {
|
||||
const settings = PersonSettings(
|
||||
pomodoro: PomodoroSettings(
|
||||
profiles: [PomodoroProfile.classic, mine],
|
||||
selectedId: 'mine',
|
||||
),
|
||||
);
|
||||
|
||||
final payload = encodeSettingsPayload(settings);
|
||||
|
||||
expect(
|
||||
decodePayload(
|
||||
payload,
|
||||
newestSchema: settingsSchemaVersion,
|
||||
)['schemaVersion'],
|
||||
settingsSchemaVersion,
|
||||
);
|
||||
expect(decodeSettingsPayload(payload), settings);
|
||||
});
|
||||
|
||||
test('as they come, are left out, in the newest schema all the same', () {
|
||||
final payload = decodePayload(
|
||||
encodeSettingsPayload(const PersonSettings()),
|
||||
newestSchema: settingsSchemaVersion,
|
||||
);
|
||||
|
||||
expect(payload['schemaVersion'], settingsSchemaVersion);
|
||||
expect(payload.keys, isNot(contains('pomodoro')));
|
||||
});
|
||||
|
||||
test('that are malformed read as they come, keeping the other '
|
||||
'settings', () {
|
||||
final settings = decodeSettingsPayload(
|
||||
encodePayload(4, {
|
||||
'studyTags': {},
|
||||
'study': {'newPerDay': 10},
|
||||
'pomodoro': {
|
||||
'profiles': [
|
||||
{'id': 'x', 'focus': 'long'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(settings.pomodoro, const PomodoroSettings());
|
||||
expect(settings.study.newPerDay, 10);
|
||||
});
|
||||
|
||||
test('folding keeps the profiles of the settings that have any', () {
|
||||
const theirs = PersonSettings(
|
||||
pomodoro: PomodoroSettings(profiles: [mine], selectedId: 'mine'),
|
||||
);
|
||||
|
||||
expect(
|
||||
const PersonSettings().foldedWith(theirs).pomodoro,
|
||||
theirs.pomodoro,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('counting learning in Explore survives a round trip, in the eighth '
|
||||
'schema; a payload from before has it off', () {
|
||||
const counted = PersonSettings(countLearning: true);
|
||||
|
||||
expect(settingsSchemaVersion, greaterThanOrEqualTo(8));
|
||||
expect(decodeSettingsPayload(encodeSettingsPayload(counted)), counted);
|
||||
expect(
|
||||
decodeSettingsPayload(
|
||||
encodePayload(7, {'studyTags': <String, Object?>{}}),
|
||||
).countLearning,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('hiding the tags from Jellyfin while studying survives a round '
|
||||
'trip, in the ninth schema, left out while off; on, on either '
|
||||
'device, it stays on as they fold', () {
|
||||
const hiding = PersonSettings(hideJellyfinTags: true);
|
||||
|
||||
expect(decodeSettingsPayload(encodeSettingsPayload(hiding)), hiding);
|
||||
expect(
|
||||
decodePayload(
|
||||
encodeSettingsPayload(const PersonSettings()),
|
||||
newestSchema: settingsSchemaVersion,
|
||||
),
|
||||
isNot(contains('hideJellyfinTags')),
|
||||
);
|
||||
expect(
|
||||
const PersonSettings().foldedWith(hiding).hideJellyfinTags,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
group('where words added from a studyset go', () {
|
||||
test('is the studyset itself until another is chosen; choosing the '
|
||||
'studyset itself forgets the choice', () {
|
||||
final settings = const PersonSettings().withAddDestination(
|
||||
'vocabulary',
|
||||
'n3',
|
||||
);
|
||||
|
||||
expect(const PersonSettings().addDestinationOf('vocabulary'), isNull);
|
||||
expect(settings.addDestinationOf('vocabulary'), 'n3');
|
||||
expect(settings.addDestinationOf('japanese'), isNull);
|
||||
expect(
|
||||
settings.withAddDestination('vocabulary', 'vocabulary').addDestinations,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('survives a round trip, in the tenth schema, left out while none '
|
||||
'is chosen; a payload from before has none', () {
|
||||
final settings = const PersonSettings().withAddDestination(
|
||||
'vocabulary',
|
||||
'n3',
|
||||
);
|
||||
|
||||
expect(settingsSchemaVersion, 10);
|
||||
expect(
|
||||
decodePayload(
|
||||
encodeSettingsPayload(settings),
|
||||
newestSchema: settingsSchemaVersion,
|
||||
)['addDestinations'],
|
||||
{'vocabulary': 'n3'},
|
||||
);
|
||||
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||
expect(
|
||||
decodePayload(
|
||||
encodeSettingsPayload(const PersonSettings()),
|
||||
newestSchema: settingsSchemaVersion,
|
||||
).keys,
|
||||
isNot(contains('addDestinations')),
|
||||
);
|
||||
expect(
|
||||
decodeSettingsPayload(
|
||||
encodePayload(9, {
|
||||
'studyTags': <String, Object?>{},
|
||||
'addDestinations': 'n3',
|
||||
}),
|
||||
).addDestinations,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('fold as ties do: for a studyset these settings win, the choices '
|
||||
'only the other has are added', () {
|
||||
final here = const PersonSettings().withAddDestination(
|
||||
'vocabulary',
|
||||
'n3',
|
||||
);
|
||||
final there = const PersonSettings()
|
||||
.withAddDestination('vocabulary', 'n5')
|
||||
.withAddDestination('kanji', 'grade-1');
|
||||
|
||||
expect(here.foldedWith(there).addDestinations, {
|
||||
'vocabulary': 'n3',
|
||||
'kanji': 'grade-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('the Jellyfin shows tied to a studyset', () {
|
||||
const show = JellyfinTie(serverId: 'server-1', itemId: 'show-1');
|
||||
const film = JellyfinTie(serverId: 'server-1', itemId: 'film-1');
|
||||
const elsewhere = JellyfinTie(serverId: 'server-2', itemId: 'show-9');
|
||||
|
||||
test('are tied once each, in the order tied, and untied', () {
|
||||
final settings = const PersonSettings()
|
||||
.withJellyfinTie('japanese', show)
|
||||
.withJellyfinTie('japanese', film)
|
||||
.withJellyfinTie('japanese', show);
|
||||
|
||||
expect(settings.jellyfinTiesOf('japanese'), [show, film]);
|
||||
expect(
|
||||
settings
|
||||
.withoutJellyfinTie('japanese', show)
|
||||
.jellyfinTiesOf('japanese'),
|
||||
[film],
|
||||
);
|
||||
expect(
|
||||
settings
|
||||
.withoutJellyfinTie('japanese', show)
|
||||
.withoutJellyfinTie('japanese', film)
|
||||
.jellyfinTies,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('survive a round trip as ids only, in the ninth schema', () {
|
||||
final settings = const PersonSettings()
|
||||
.withJellyfinTie('japanese', show)
|
||||
.withJellyfinTie('japanese', elsewhere);
|
||||
|
||||
final payload = decodePayload(
|
||||
encodeSettingsPayload(settings),
|
||||
newestSchema: settingsSchemaVersion,
|
||||
);
|
||||
|
||||
expect(settingsSchemaVersion, greaterThanOrEqualTo(9));
|
||||
expect(payload['jellyfinTies'], {
|
||||
'japanese': [
|
||||
{'server': 'server-1', 'item': 'show-1'},
|
||||
{'server': 'server-2', 'item': 'show-9'},
|
||||
],
|
||||
});
|
||||
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||
});
|
||||
|
||||
test('are left out while there are none; a payload from before has '
|
||||
'none', () {
|
||||
expect(
|
||||
decodePayload(
|
||||
encodeSettingsPayload(const PersonSettings()),
|
||||
newestSchema: settingsSchemaVersion,
|
||||
).keys,
|
||||
isNot(contains('jellyfinTies')),
|
||||
);
|
||||
expect(
|
||||
decodeSettingsPayload(
|
||||
encodePayload(8, {'studyTags': <String, Object?>{}}),
|
||||
).jellyfinTies,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('that are malformed are skipped, keeping the rest', () {
|
||||
final settings = decodeSettingsPayload(
|
||||
encodePayload(9, {
|
||||
'studyTags': <String, Object?>{},
|
||||
'study': {'newPerDay': 10},
|
||||
'jellyfinTies': {
|
||||
'japanese': [
|
||||
{'server': 'server-1', 'item': 'show-1'},
|
||||
{'server': 'server-1'},
|
||||
'show-2',
|
||||
],
|
||||
'korean': 'show-3',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(settings.jellyfinTies, {
|
||||
'japanese': [show],
|
||||
});
|
||||
expect(settings.study.newPerDay, 10);
|
||||
});
|
||||
|
||||
test('fold as tags do: for a studyset these settings win, ties of '
|
||||
'studysets only the other has are added', () {
|
||||
final mine = const PersonSettings().withJellyfinTie('japanese', show);
|
||||
final theirs = const PersonSettings()
|
||||
.withJellyfinTie('japanese', film)
|
||||
.withJellyfinTie('korean', elsewhere);
|
||||
|
||||
expect(mine.foldedWith(theirs).jellyfinTies, {
|
||||
'japanese': [show],
|
||||
'korean': [elsewhere],
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/device_dictionary.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../support/fake_device_dictionaries.dart';
|
||||
import '../../../support/fake_settings_gateway.dart';
|
||||
import '../../../support/memory_device_settings_store.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
void main() {
|
||||
/// How bright the app is where [finder] shows.
|
||||
Brightness brightnessAt(WidgetTester tester, Finder finder) =>
|
||||
Theme.of(tester.element(finder)).brightness;
|
||||
|
||||
Future<void> choose(WidgetTester tester, String from, String to) async {
|
||||
await tester.tap(find.text(from));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(to).last);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('Home opens the settings', (tester) async {
|
||||
await pumpApp(tester, testDependencies());
|
||||
|
||||
await tester.tap(find.byTooltip('Settings'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Appearance'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows the version and build at the bottom', (tester) async {
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
appVersion: const AppVersion(version: '1.2.3', build: '45'),
|
||||
),
|
||||
location: RoutePaths.settings,
|
||||
);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Version 1.2.3 (build 45)'),
|
||||
200,
|
||||
// The preview's citation is selectable, a scroll view of its own.
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
expect(find.text('Version 1.2.3 (build 45)'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('Japanese on this device', () {
|
||||
/// Opens Settings, tall enough to show every card at once.
|
||||
Future<void> openSettings(
|
||||
WidgetTester tester,
|
||||
FakeDeviceDictionaryGateway gateway, {
|
||||
DeviceDictionaryStatus initial = const DeviceDictionaryStatus(),
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(800, 5000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
deviceDictionaries: deviceDictionariesIn(gateway, initial: initial),
|
||||
),
|
||||
location: RoutePaths.settings,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('downloads the dictionaries the instance offers devices, and '
|
||||
'says when that failed', (tester) async {
|
||||
final gateway = FakeDeviceDictionaryGateway(offered: offeredJapanese());
|
||||
await openSettings(tester, gateway);
|
||||
|
||||
expect(find.text('Japanese on this device'), findsOneWidget);
|
||||
gateway.fail = const SocketException('offline');
|
||||
await tester.tap(find.text('Download (34.0 MB)'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.asked, 2);
|
||||
expect(find.text('The download failed.'), findsOneWidget);
|
||||
expect(find.text('Download (34.0 MB)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('says how much room they take, and removes them', (
|
||||
tester,
|
||||
) async {
|
||||
await openSettings(
|
||||
tester,
|
||||
FakeDeviceDictionaryGateway(offered: offeredJapanese()),
|
||||
initial: DeviceDictionaryStatus(
|
||||
installed: installedJapanese(),
|
||||
asked: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('On this device: 150.0 MB'), findsOneWidget);
|
||||
await tester.tap(find.text('Remove'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('On this device: 150.0 MB'), findsNothing);
|
||||
expect(find.text('Download (34.0 MB)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows nothing of them where the instance offers none and '
|
||||
'the device holds none', (tester) async {
|
||||
await openSettings(tester, FakeDeviceDictionaryGateway());
|
||||
|
||||
expect(find.text('Japanese on this device'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('a device that never chose follows its own theme', (
|
||||
tester,
|
||||
) async {
|
||||
tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark;
|
||||
addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue);
|
||||
await pumpApp(tester, testDependencies(), location: RoutePaths.settings);
|
||||
|
||||
expect(find.text('Device theme'), findsOneWidget);
|
||||
expect(find.text('Device language'), findsOneWidget);
|
||||
expect(brightnessAt(tester, find.text('Appearance')), Brightness.dark);
|
||||
});
|
||||
|
||||
testWidgets('a forced appearance applies at once and is kept', (
|
||||
tester,
|
||||
) async {
|
||||
final store = MemoryDeviceSettingsStore();
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(deviceSettingsStore: store),
|
||||
location: RoutePaths.settings,
|
||||
);
|
||||
|
||||
await choose(tester, 'Device theme', 'Dark');
|
||||
|
||||
expect(brightnessAt(tester, find.text('Appearance')), Brightness.dark);
|
||||
expect(
|
||||
store.writes.last,
|
||||
const DeviceSettings(appearance: Appearance.dark),
|
||||
);
|
||||
|
||||
await choose(tester, 'Dark', 'Light');
|
||||
|
||||
expect(brightnessAt(tester, find.text('Appearance')), Brightness.light);
|
||||
});
|
||||
|
||||
testWidgets('the citation style chosen shows on a sample at once and is '
|
||||
'kept', (tester) async {
|
||||
final store = MemoryDeviceSettingsStore();
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(deviceSettingsStore: store),
|
||||
location: RoutePaths.settings,
|
||||
);
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Official guide'),
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
|
||||
expect(find.textContaining('Brown, P. C., Roediger'), findsOneWidget);
|
||||
|
||||
await choose(tester, 'APA (7th edition)', 'MLA (9th edition)');
|
||||
|
||||
expect(find.textContaining('Brown, Peter C., et al.'), findsOneWidget);
|
||||
expect(
|
||||
store.writes.last,
|
||||
const DeviceSettings(citationStyle: CitationStyle.mla),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the Study card hides the Pomodoro timer while studying, and '
|
||||
'opens the Pomodoro profiles', (tester) async {
|
||||
final store = MemoryDeviceSettingsStore();
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(deviceSettingsStore: store),
|
||||
location: RoutePaths.settings,
|
||||
);
|
||||
final timer = find.text('Show the Pomodoro timer while studying');
|
||||
await tester.scrollUntilVisible(
|
||||
timer,
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
expect(find.text('Pomodoro profiles'), findsOneWidget);
|
||||
|
||||
final toggle = find.widgetWithText(
|
||||
SwitchListTile,
|
||||
'Show the Pomodoro timer while studying',
|
||||
);
|
||||
await tester.ensureVisible(toggle);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.descendant(of: toggle, matching: find.byType(Switch)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(store.writes.last.pomodoroWhileStudying, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('starts with the appearance the device kept', (tester) async {
|
||||
tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark;
|
||||
addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
deviceSettings: const DeviceSettings(appearance: Appearance.light),
|
||||
),
|
||||
location: RoutePaths.settings,
|
||||
);
|
||||
|
||||
expect(find.text('Light'), findsOneWidget);
|
||||
expect(brightnessAt(tester, find.text('Appearance')), Brightness.light);
|
||||
});
|
||||
|
||||
testWidgets('the device language comes first, then each language in its '
|
||||
'own name', (tester) async {
|
||||
await pumpApp(tester, testDependencies(), location: RoutePaths.settings);
|
||||
|
||||
await tester.tap(find.text('Device language'));
|
||||
await tester.pumpAndSettle();
|
||||
final menu = find.byWidgetPredicate(
|
||||
(widget) => widget is DropdownMenuItem<String?>,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widgetList<DropdownMenuItem<String?>>(menu)
|
||||
.map((item) => item.value)
|
||||
.toSet()
|
||||
.toList(),
|
||||
[null, 'en-GB', 'en-US', 'de'],
|
||||
);
|
||||
for (final name in ['English (UK)', 'English (US)', 'Deutsch']) {
|
||||
expect(find.text(name), findsWidgets);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('a language chosen applies at once and is kept, until the '
|
||||
'device language is chosen again', (tester) async {
|
||||
final store = MemoryDeviceSettingsStore();
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(deviceSettingsStore: store),
|
||||
location: RoutePaths.settings,
|
||||
);
|
||||
|
||||
await choose(tester, 'Device language', 'Deutsch');
|
||||
|
||||
expect(find.text('Einstellungen'), findsOneWidget);
|
||||
expect(find.text('Darstellung'), findsOneWidget);
|
||||
expect(store.writes.last, const DeviceSettings(language: 'de'));
|
||||
|
||||
await choose(tester, 'Deutsch', 'Gerätesprache');
|
||||
|
||||
expect(find.text('Settings'), findsOneWidget);
|
||||
expect(store.writes.last, const DeviceSettings());
|
||||
});
|
||||
|
||||
testWidgets('starts in the language the device kept', (tester) async {
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(deviceSettings: const DeviceSettings(language: 'de')),
|
||||
location: RoutePaths.settings,
|
||||
);
|
||||
|
||||
expect(find.text('Einstellungen'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('appearance and language', () {
|
||||
Future<void> pumpAt(WidgetTester tester, double width) async {
|
||||
tester.view.physicalSize = Size(width, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(tester, testDependencies(), location: RoutePaths.settings);
|
||||
}
|
||||
|
||||
testWidgets('sit side by side where there is room', (tester) async {
|
||||
await pumpAt(tester, 1000);
|
||||
|
||||
expect(
|
||||
tester.getTopLeft(find.text('Language')).dy,
|
||||
tester.getTopLeft(find.text('Appearance')).dy,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('sit one below the other on a phone', (tester) async {
|
||||
await pumpAt(tester, 400);
|
||||
|
||||
expect(
|
||||
tester.getTopLeft(find.text('Language')).dy,
|
||||
greaterThan(tester.getBottomLeft(find.text('Device theme')).dy),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets("a person's learning counts in Explore only once they choose "
|
||||
'so, on every device of theirs', (tester) async {
|
||||
final settings = FakeSettingsGateway();
|
||||
tester.view.physicalSize = const Size(900, 2400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(settingsGateway: settings),
|
||||
location: RoutePaths.settings,
|
||||
);
|
||||
final counting = find.widgetWithText(
|
||||
SwitchListTile,
|
||||
'Count my learning in Explore',
|
||||
);
|
||||
expect(tester.widget<SwitchListTile>(counting).value, isFalse);
|
||||
|
||||
await tester.tap(counting);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.widget<SwitchListTile>(counting).value, isTrue);
|
||||
expect(
|
||||
(await SettingsRepository(settings).read()).countLearning,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue