chore: Halkyon Learn moves into apps/learn

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

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

View file

@ -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;
});
}

View file

@ -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],
});
});
});
}

View file

@ -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,
);
});
}