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
12 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,174 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_editor_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.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/step_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
void main() {
late LearningPathRepository paths;
late TagRepository tags;
late StepDeletions stepDeletions;
late String pathId;
PathEditorBloc? bloc;
setUp(() async {
paths = LearningPathRepository(FakeLearningPathGateway());
tags = TagRepository(FakeTagGateway(), pathChanges: paths.changes);
stepDeletions = StepDeletions(
paths,
undoWindow: const Duration(hours: 1),
);
pathId = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
});
tearDown(() async {
await bloc?.close();
bloc = null;
await stepDeletions.dispose();
await tags.dispose();
await paths.dispose();
});
PathEditorBloc open({Duration idle = const Duration(milliseconds: 20)}) =>
bloc = PathEditorBloc(
paths,
tags,
stepDeletions,
'japanese',
pathId,
idleSave: idle,
)..add(const PathEditorEventStarted());
Future<PathEditorState> until(bool Function(PathEditorState) test) {
if (test(bloc!.state)) {
return Future.value(bloc!.state);
}
return bloc!.stream.firstWhere(test);
}
Future<LearningPathContent> stored() async =>
(await paths.pathsOf('japanese')).single.content;
test('opens the path with its steps in order', () async {
await paths.createStep(
pathId,
StepKind.info,
StepContent.empty(StepKind.info),
);
await paths.createStep(
pathId,
StepKind.singleChoice,
StepContent.empty(StepKind.singleChoice),
);
open();
final state = await until((s) => s.loaded && s.steps.length == 2);
expect(state.content.title, 'Particles');
expect(
[for (final s in state.steps) s.kind],
[
StepKind.info,
StepKind.singleChoice,
],
);
});
test('saves the title, secondary title and description after a pause in '
'typing', () async {
open();
await until((s) => s.loaded);
bloc!
..add(const PathEditorEventTitleChanged('Particles '))
..add(const PathEditorEventSecondaryTitleChanged('助詞'))
..add(PathEditorEventDescriptionChanged(RichContent.plain('は and が')));
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(
await stored(),
LearningPathContent(
title: 'Particles',
secondaryTitle: '助詞',
description: RichContent.plain('は and が'),
),
);
});
test('closing saves what is unsaved; a blank title is never saved', () async {
open(idle: const Duration(hours: 1));
await until((s) => s.loaded);
bloc!.add(const PathEditorEventTitleChanged(' '));
await Future<void>.delayed(Duration.zero);
bloc!.add(const PathEditorEventSecondaryTitleChanged('助詞'));
await until((s) => s.content.secondaryTitle == '助詞');
await bloc!.close();
expect((await stored()).title, 'Particles');
expect((await stored()).secondaryTitle, isNull);
});
test('adds a step of a kind at the end, and hands back its id', () async {
open();
await until((s) => s.loaded);
bloc!.add(const PathEditorEventStepAdded(StepKind.fillInTheBlank));
final added = await until((s) => s.added != null);
final steps = await paths.stepsOf(pathId);
expect(steps.single.id, added.added);
expect(steps.single.kind, StepKind.fillInTheBlank);
});
test('moves a step, and leaves out one whose deletion waits', () async {
final a = await paths.createStep(
pathId,
StepKind.info,
StepContent.empty(StepKind.info).withField(StepField.title, 'a'),
);
await paths.createStep(
pathId,
StepKind.info,
StepContent.empty(StepKind.info).withField(StepField.title, 'b'),
);
open();
await until((s) => s.steps.length == 2);
bloc!.add(PathEditorEventStepMoved(a.stepId, 1));
await until((s) => s.steps.first.content.title == 'b');
stepDeletions.delete(a.stepId);
final waiting = await until((s) => s.steps.length == 1);
expect(waiting.steps.single.content.title, 'b');
});
test('stores the tags chosen at once', () async {
final grammar = await tags.create('grammar');
open();
await until((s) => s.loaded);
bloc!.add(PathEditorEventTagsChanged({grammar}));
await until((s) => s.savedTagIds.contains(grammar));
expect((await tags.read()).pathsWith(grammar), {pathId});
});
test('a path deleted elsewhere is gone', () async {
open();
await until((s) => s.loaded);
await paths.deletePath(pathId);
await until((s) => s.gone);
});
}

View file

@ -0,0 +1,223 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.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_import.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 '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_path_answer_gateway.dart';
void main() {
late LearningPathRepository paths;
late PathProgressRepository progress;
late LearningPathDeletions deletions;
late PathListBloc bloc;
setUp(() {
paths = LearningPathRepository(FakeLearningPathGateway());
progress = PathProgressRepository(FakePathAnswerGateway());
deletions = LearningPathDeletions(
paths,
undoWindow: const Duration(hours: 1),
);
bloc = PathListBloc(paths, progress, deletions, 'japanese')
..add(const PathListEventStarted());
});
tearDown(() async {
await bloc.close();
await deletions.dispose();
await progress.dispose();
await paths.dispose();
});
Future<PathListState> until(bool Function(PathListState) test) {
if (test(bloc.state)) {
return Future.value(bloc.state);
}
return bloc.stream.firstWhere(test);
}
Future<String> path(String title) =>
paths.createPath('japanese', LearningPathContent.titled(title));
Future<String> info(String pathId) async => (await paths.createStep(
pathId,
StepKind.info,
StepContent.empty(StepKind.info),
)).stepId;
test('lists the paths in order, leaving out one whose deletion '
'waits', () async {
await path('Kana');
final particles = await path('Particles');
await until((s) => s.paths.length == 2);
deletions.delete(particles);
final waiting = await until((s) => s.paths.length == 1);
expect(waiting.paths.single.content.title, 'Kana');
});
test('a path is completed once every step is done, never without '
'steps', () async {
final kana = await path('Kana');
final empty = await path('Empty');
final first = await info(kana);
final second = await info(kana);
await until((s) => s.completionKnown && s.paths.length == 2);
await progress.record(first, AnswerOutcome.continued);
final half = await until((s) => s.completionKnown);
expect(half.completed, isEmpty);
await progress.record(second, AnswerOutcome.continued);
final done = await until((s) => s.completed.contains(kana));
expect(done.completed, {kana});
expect(done.completed, isNot(contains(empty)));
expect(done.hasOpenPaths, isTrue);
});
test('a fresh start opens a completed path again, and undoing it closes '
'it', () async {
final kana = await path('Kana');
await progress.record(await info(kana), AnswerOutcome.continued);
await until((s) => s.completed.contains(kana));
bloc.add(PathListEventResetRequested(kana));
await until((s) => s.completionKnown && s.completed.isEmpty);
bloc.add(const PathListEventResetUndone());
await until((s) => s.completed.contains(kana));
});
test('imports a path from the import format, and says what came in, or '
'why nothing did', () async {
await until((s) => s.loaded);
bloc.add(
const PathListEventImportRequested([
(
name: 'particles.json',
source:
'{"path": {"title": "Particles", "steps": ['
'{"title": "Read", "type": "info", "content": ['
'{"type": "image", "src": "https://example.org/x.png"}]}]}}',
),
]),
);
await until((s) => s.importing);
final imported = await until((s) => s.importResult != null);
expect(imported.importing, isFalse);
expect(imported.importResult, (
title: 'Particles',
paths: 1,
steps: 1,
skippedBlocks: 1,
file: null,
failure: null,
));
await until((s) => s.paths.any((p) => p.content.title == 'Particles'));
bloc.add(
const PathListEventImportRequested([(name: 'broken.json', source: '{')]),
);
final failed = await until((s) => s.importResult?.failure != null);
expect(failed.importResult!.failure?.problem, PathImportProblem.notJson);
expect(failed.importResult!.file, isNull);
});
test('imports several paths in the order given, or none if one of the '
'files is broken, naming it', () async {
await until((s) => s.loaded);
String path(String title) =>
'{"path": {"title": "$title", "steps": ['
'{"title": "Read", "type": "info"}, {"title": "More", "type": "info"}]}}';
bloc.add(
PathListEventImportRequested([
(name: '01.json', source: path('State-of-being')),
(name: '02.json', source: '{'),
]),
);
final failed = await until((s) => s.importResult?.failure != null);
expect(failed.importResult!.file, '02.json');
expect(failed.paths, isEmpty);
bloc.add(
PathListEventImportRequested([
(name: '01.json', source: path('State-of-being')),
(name: '02.json', source: path('Particles')),
]),
);
final imported = await until((s) => s.importResult?.paths == 2);
expect(imported.importResult, (
title: 'State-of-being',
paths: 2,
steps: 4,
skippedBlocks: 0,
file: null,
failure: null,
));
final listed = await until((s) => s.paths.length == 2);
expect(
[for (final p in listed.paths) p.content.title],
[
'State-of-being',
'Particles',
],
);
// The list is read anew after each of the import's writes.
await pumpEventQueue();
});
test('says how far an import has come: the path being stored, which of '
'how many, and the share stored, path and steps alike, the steps of '
'a path stored together', () async {
await until((s) => s.loaded);
final progress = <PathImportProgress?>[];
final listening = bloc.stream.listen(
(state) => progress.add(state.importProgress),
);
addTearDown(listening.cancel);
String path(String title) =>
'{"path": {"title": "$title", "steps": ['
'{"title": "Read", "type": "info"}, {"title": "More", "type": "info"}]}}';
bloc.add(
PathListEventImportRequested([
(name: '01.json', source: path('State-of-being')),
(name: '02.json', source: path('Particles')),
]),
);
await until((s) => s.importResult != null);
await pumpEventQueue();
final reported = progress.nonNulls.toList();
// Reading first, then each path with its steps: six shares.
expect(reported.first, (title: '', path: 0, paths: 2, done: 0.0));
expect(
{for (final p in reported.skip(1)) (p.path, p.title)},
{(1, 'State-of-being'), (2, 'Particles')},
);
expect(
{for (final p in reported) p.done},
{0.0, 1 / 6, 3 / 6, 4 / 6, 1.0},
);
expect(bloc.state.importProgress, isNull);
expect(bloc.state.importing, isFalse);
});
test('creating a path hands back its id, and lists it', () async {
await until((s) => s.loaded);
bloc.add(PathListEventCreateRequested(LearningPathContent.titled('Verbs')));
final created = await until((s) => s.created != null);
await until((s) => s.paths.any((p) => p.id == created.created));
});
}

View file

@ -0,0 +1,189 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_bloc.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/answer.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 '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_path_answer_gateway.dart';
void main() {
late LearningPathRepository paths;
late PathProgressRepository progress;
late String pathId;
late String nextPathId;
late String infoId;
late String choiceId;
PathRunnerBloc? bloc;
setUp(() async {
paths = LearningPathRepository(FakeLearningPathGateway());
progress = PathProgressRepository(FakePathAnswerGateway());
pathId = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
nextPathId = await paths.createPath(
'japanese',
LearningPathContent.titled('Verbs'),
);
infoId = (await paths.createStep(
pathId,
StepKind.info,
StepContent.empty(StepKind.info).withField(StepField.title, 'Read'),
)).stepId;
choiceId = (await paths.createStep(
pathId,
StepKind.singleChoice,
StepContent.empty(StepKind.singleChoice)
.withField(StepField.title, 'Choose')
.withField(
StepField.exercise,
const SingleChoice(options: ['は', 'が'], correct: 0),
),
)).stepId;
});
tearDown(() async {
await bloc?.close();
bloc = null;
await progress.dispose();
await paths.dispose();
});
PathRunnerBloc open() => bloc = PathRunnerBloc(
paths,
progress,
'japanese',
pathId,
)..add(const PathRunnerEventStarted());
Future<PathRunnerState> until(bool Function(PathRunnerState) test) {
if (test(bloc!.state)) {
return Future.value(bloc!.state);
}
return bloc!.stream.firstWhere(test);
}
Future<bool> done(String stepId) async =>
(await progress.of({
infoId: StepKind.info,
choiceId: StepKind.singleChoice,
}))[stepId]?.done ??
false;
test('opens at the first step, and passing an info step records it and '
'goes on', () async {
open();
final first = await until((s) => s.loaded);
expect(first.step?.id, infoId);
expect(first.canContinue, isTrue);
bloc!.add(const PathRunnerEventContinued());
final second = await until((s) => s.index == 1);
expect(second.step?.id, choiceId);
expect(await done(infoId), isTrue);
});
test('resumes at the first unfinished step', () async {
await progress.record(infoId, AnswerOutcome.continued);
open();
expect((await until((s) => s.loaded)).step?.id, choiceId);
});
test('an exercise is checked, and goes on only once right', () async {
await progress.record(infoId, AnswerOutcome.continued);
open();
await until((s) => s.loaded);
expect(bloc!.state.canCheck, isFalse);
bloc!
..add(const PathRunnerEventAnswered(SingleChoiceAnswer(1)))
..add(const PathRunnerEventCheckRequested());
final wrong = await until((s) => s.grade != null);
expect(wrong.grade, const Grade(right: false, wrongPicks: {1}));
expect(wrong.canContinue, isFalse);
expect(await done(choiceId), isFalse);
bloc!.add(const PathRunnerEventAnswered(SingleChoiceAnswer(0)));
final changed = await until((s) => s.grade == null);
expect(changed.canCheck, isTrue);
bloc!.add(const PathRunnerEventCheckRequested());
final right = await until((s) => s.grade?.right ?? false);
expect(right.canContinue, isTrue);
expect(await done(choiceId), isTrue);
});
test('a step done before shows the right answer and can be passed', () async {
await progress.record(infoId, AnswerOutcome.continued);
await progress.record(
choiceId,
AnswerOutcome.right,
const SingleChoiceAnswer(0),
);
open();
await until((s) => s.loaded);
bloc!.add(const PathRunnerEventBack());
await until((s) => s.index == 0);
bloc!.add(const PathRunnerEventContinued());
final again = await until((s) => s.index == 1);
expect(again.alreadyDone, isTrue);
expect(again.answer, const SingleChoiceAnswer(0));
expect(again.canContinue, isTrue);
});
test(
'continuing past the last step finishes, naming the next path',
() async {
await progress.record(infoId, AnswerOutcome.continued);
await progress.record(
choiceId,
AnswerOutcome.right,
const SingleChoiceAnswer(0),
);
open();
await until((s) => s.loaded && s.index == 0);
bloc!.add(const PathRunnerEventContinued());
await until((s) => s.index == 1);
expect(bloc!.state.isLast, isTrue);
bloc!.add(const PathRunnerEventContinued());
final finished = await until((s) => s.finished);
expect(finished.nextPathId, nextPathId);
expect(finished.steps, hasLength(2));
},
);
test('restarted after a reset, it shows the first step afresh, also from '
'the end', () async {
await progress.record(infoId, AnswerOutcome.continued);
await progress.record(
choiceId,
AnswerOutcome.right,
const SingleChoiceAnswer(0),
);
open();
await until((s) => s.loaded && s.index == 0);
bloc!.add(const PathRunnerEventContinued());
await until((s) => s.index == 1);
bloc!.add(const PathRunnerEventContinued());
await until((s) => s.finished);
await progress.reset({infoId, choiceId});
bloc!.add(const PathRunnerEventRestarted());
final afresh = await until((s) => !s.finished && s.index == 0);
expect(afresh.step?.id, infoId);
expect(afresh.progress[infoId]?.done, isFalse);
expect(afresh.answer, isNull);
});
}

View file

@ -0,0 +1,164 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/editor/save_status.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/step_editor_bloc.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/learning_path_step_payload.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/step_content.dart';
import '../../../support/fake_learning_path_gateway.dart';
void main() {
late FakeLearningPathGateway gateway;
late LearningPathRepository paths;
late String pathId;
late ({String stepId, String revisionId}) created;
StepEditorBloc? bloc;
const choice = SingleChoice(options: ['は', 'が'], correct: 0);
StepContent step(String title, [Exercise? exercise = choice]) =>
StepContent.empty(StepKind.singleChoice)
.withField(StepField.title, title)
.withField(StepField.exercise, exercise);
setUp(() async {
gateway = FakeLearningPathGateway();
paths = LearningPathRepository(gateway);
pathId = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
created = await paths.createStep(
pathId,
StepKind.singleChoice,
step('Which marks the topic?'),
);
});
tearDown(() async {
await bloc?.close();
bloc = null;
await paths.dispose();
});
StepEditorBloc open({Duration idle = const Duration(milliseconds: 20)}) =>
bloc = StepEditorBloc(paths, pathId, created.stepId, idleSave: idle)
..add(const StepEditorEventStarted());
Future<StepEditorState> until(bool Function(StepEditorState) test) {
if (test(bloc!.state)) {
return Future.value(bloc!.state);
}
return bloc!.stream.firstWhere(test);
}
Future<StepContent> stored() async =>
(await paths.stepsOf(pathId)).single.content;
test('opens the step of its kind with its content', () async {
open();
final state = await until((s) => s.loaded);
expect(state.kind, StepKind.singleChoice);
expect(state.content, step('Which marks the topic?'));
});
test('saves after a pause, each save on the one before', () async {
open();
await until((s) => s.loaded);
bloc!.add(const StepEditorEventChanged(StepField.title, 'は or が?'));
await until((s) => s.status == EditorSaveStatus.saved);
bloc!.add(
const StepEditorEventChanged(
StepField.exercise,
SingleChoice(options: ['は', 'が', 'を'], correct: 0),
),
);
await until(
(s) =>
s.status == EditorSaveStatus.saved &&
s.saved.exercise ==
const SingleChoice(options: ['は', 'が', 'を'], correct: 0),
);
expect(gateway.written[1].parents, {gateway.written[0].id});
expect(gateway.written[2].parents, {gateway.written[1].id});
expect((await stored()).title, 'は or が?');
});
test('closing saves what is unsaved', () async {
open(idle: const Duration(hours: 1));
await until((s) => s.loaded);
bloc!.add(const StepEditorEventChanged(StepField.title, 'は or が?'));
await until((s) => s.status == EditorSaveStatus.editing);
await bloc!.close();
expect((await stored()).title, 'は or が?');
});
test("leaves the person's unsaved change alone when another device "
'changes the step, and the save merges with it', () async {
open(idle: const Duration(hours: 1));
await until((s) => s.loaded);
bloc!.add(const StepEditorEventChanged(StepField.title, 'は or が?'));
await until((s) => s.status == EditorSaveStatus.editing);
const fixed = SingleChoice(options: ['は', 'が', 'を'], correct: 0);
gateway.receiveRemoteRevision(
id: 'srev-9000',
stepId: created.stepId,
parents: {created.revisionId},
payload: encodeStepPayload(step('Which marks the topic?', fixed)),
);
await until((s) => s.saved.exercise == fixed);
bloc!.add(const StepEditorEventSaveRequested());
await until((s) => s.content.exercise == fixed);
expect(await stored(), step('は or が?', fixed));
});
test('an exercise both changed is a conflict: read-only until a version '
'is kept, which settles it', () async {
open(idle: const Duration(hours: 1));
await until((s) => s.loaded);
const mine = SingleChoice(options: ['は', 'が'], correct: 1);
const theirs = SingleChoice(options: ['は', 'が', 'を'], correct: 0);
bloc!.add(const StepEditorEventChanged(StepField.exercise, mine));
await until((s) => s.status == EditorSaveStatus.editing);
gateway.receiveRemoteRevision(
id: 'srev-9000',
stepId: created.stepId,
parents: {created.revisionId},
payload: encodeStepPayload(step('Which marks the topic?', theirs)),
);
await until((s) => s.saved.exercise == theirs);
bloc!.add(const StepEditorEventSaveRequested());
final conflicted = await until((s) => s.unresolved.isNotEmpty);
expect(conflicted.conflicts[StepField.exercise], [mine, theirs]);
final writes = gateway.written.length;
bloc!.add(const StepEditorEventChanged(StepField.title, 'ignored'));
await Future<void>.delayed(const Duration(milliseconds: 30));
expect(bloc!.state.content.title, 'Which marks the topic?');
bloc!.add(const StepEditorEventVersionChosen(StepField.exercise, mine));
await until(
(s) => s.conflicts.isEmpty && s.status == EditorSaveStatus.saved,
);
expect(gateway.written.length, writes + 1);
expect((await stored()).exercise, mine);
});
test('a step deleted elsewhere is gone', () async {
open();
await until((s) => s.loaded);
await paths.deleteStep(created.stepId);
await until((s) => s.gone);
});
}

View file

@ -0,0 +1,287 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_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/learning_path_step_payload.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/step_content.dart';
import '../../../support/fake_learning_path_gateway.dart';
void main() {
late FakeLearningPathGateway gateway;
late LearningPathRepository paths;
setUp(() {
gateway = FakeLearningPathGateway();
paths = LearningPathRepository(gateway);
});
tearDown(() => paths.dispose());
StepContent titled(StepKind kind, String title) =>
StepContent.empty(kind).withField(StepField.title, title);
group('Paths', () {
test('a new path goes after the others, its titles trimmed', () async {
await paths.createPath('japanese', LearningPathContent.titled('Kana'));
await paths.createPath(
'japanese',
LearningPathContent(
title: ' Particles ',
secondaryTitle: ' ',
description: RichContent.empty(),
),
);
await paths.createPath('german', LearningPathContent.titled('Cases'));
final listed = await paths.pathsOf('japanese');
expect([for (final p in listed) p.content.title], ['Kana', 'Particles']);
expect(listed.last.content.secondaryTitle, isNull);
});
test('edits a path, the last writer winning', () async {
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Kana'),
);
await paths.updatePath(
id,
LearningPathContent(
title: 'Kana',
secondaryTitle: '仮名',
description: RichContent.empty(),
),
);
expect(
(await paths.pathsOf('japanese')).single.content.secondaryTitle,
'仮名',
);
});
test('moves a path between two others', () async {
for (final title in ['a', 'b', 'c']) {
await paths.createPath('japanese', LearningPathContent.titled(title));
}
final third = (await paths.pathsOf('japanese')).last;
await paths.movePath('japanese', third.id, toIndex: 0);
expect(
[for (final p in await paths.pathsOf('japanese')) p.content.title],
['c', 'a', 'b'],
);
});
test('counts the paths of every studyset', () async {
await paths.createPath('japanese', LearningPathContent.titled('a'));
await paths.createPath('japanese', LearningPathContent.titled('b'));
await paths.createPath('german', LearningPathContent.titled('c'));
expect(await paths.countsByStudyset(), {'japanese': 2, 'german': 1});
});
test('deleting a path blanks and deletes its steps first, then itself, '
'blanked', () async {
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Secret'),
);
final step = await paths.createStep(
id,
StepKind.info,
titled(StepKind.info, 'Secret step'),
);
await paths.deletePath(id);
expect(gateway.deletions, [
'blank step ${step.stepId}',
'delete step ${step.stepId}',
'blank path $id',
'delete path $id',
]);
expect(gateway.revisionPayloads.values, everyElement(redactedPayload));
expect(await paths.pathsOf('japanese'), isEmpty);
});
test("deletes a studyset's paths and no other's", () async {
await paths.createPath('japanese', LearningPathContent.titled('a'));
await paths.createPath('german', LearningPathContent.titled('b'));
await paths.deleteAllOf('japanese');
expect(await paths.pathsOf('japanese'), isEmpty);
expect(await paths.pathsOf('german'), hasLength(1));
});
});
group('Steps', () {
late String path;
setUp(() async {
path = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
});
test('a new step goes after the others, of the kind it was made', () async {
await paths.createStep(
path,
StepKind.info,
titled(StepKind.info, 'Read'),
);
await paths.createStep(
path,
StepKind.singleChoice,
titled(StepKind.singleChoice, 'Choose'),
);
final steps = await paths.stepsOf(path);
expect([for (final s in steps) s.content.title], ['Read', 'Choose']);
expect(
[for (final s in steps) s.kind],
[
StepKind.info,
StepKind.singleChoice,
],
);
});
test('steps made together go after the others in their order, as one '
'change', () async {
await paths.createStep(
path,
StepKind.info,
titled(StepKind.info, 'First'),
);
var changes = 0;
final counting = paths.changes.listen((_) => changes++);
addTearDown(counting.cancel);
final ids = await paths.createSteps(path, [
(kind: StepKind.info, content: titled(StepKind.info, 'Read')),
(
kind: StepKind.singleChoice,
content: titled(StepKind.singleChoice, 'Choose'),
),
]);
await pumpEventQueue();
final steps = await paths.stepsOf(path);
expect(
[for (final s in steps) s.content.title],
[
'First',
'Read',
'Choose',
],
);
expect([for (final s in steps.skip(1)) s.id], ids);
expect(steps.last.kind, StepKind.singleChoice);
expect(changes, 1);
});
test('an edit saved on what it started from merges with another '
"device's", () async {
final created = await paths.createStep(
path,
StepKind.singleChoice,
titled(StepKind.singleChoice, 'Particles'),
);
gateway.receiveRemoteRevision(
id: 'srev-9000',
stepId: created.stepId,
parents: {created.revisionId},
payload: encodeStepPayload(
titled(StepKind.singleChoice, 'Particles').withField(
StepField.exercise,
const SingleChoice(options: ['は', 'が'], correct: 0),
),
),
);
await paths.saveStep(
created.stepId,
titled(StepKind.singleChoice, 'は or が?'),
basedOn: {created.revisionId},
);
final merged = (await paths.stepsOf(path)).single;
expect(merged.content.title, 'は or が?');
expect(
merged.content.exercise,
const SingleChoice(options: ['は', 'が'], correct: 0),
);
expect(merged.hasConflict, isFalse);
});
test('a step of a kind this app does not know is left out', () async {
await paths.createStep(path, StepKind.info, titled(StepKind.info, 'a'));
await gateway.insertStep(
pathId: path,
position: 'z0',
kind: 'hologram',
payload: encodeStepPayload(titled(StepKind.info, 'future')),
);
expect(await paths.stepsOf(path), hasLength(1));
});
test('moves a step, and deletes one blanked first', () async {
final a = await paths.createStep(
path,
StepKind.info,
titled(StepKind.info, 'a'),
);
await paths.createStep(path, StepKind.info, titled(StepKind.info, 'b'));
await paths.moveStep(path, a.stepId, toIndex: 1);
expect(
[for (final s in await paths.stepsOf(path)) s.content.title],
[
'b',
'a',
],
);
await paths.deleteStep(a.stepId);
expect(gateway.deletions, [
'blank step ${a.stepId}',
'delete step ${a.stepId}',
]);
expect(
[for (final s in await paths.stepsOf(path)) s.content.title],
[
'b',
],
);
});
test('tells of every change, made here or synced from elsewhere', () async {
var changes = 0;
final subscription = paths.changes.listen((_) => changes++);
addTearDown(subscription.cancel);
final created = await paths.createStep(
path,
StepKind.info,
titled(StepKind.info, 'a'),
);
gateway.receiveRemoteRevision(
id: 'srev-9000',
stepId: created.stepId,
parents: {created.revisionId},
payload: encodeStepPayload(titled(StepKind.info, 'b')),
);
await Future<void>.delayed(Duration.zero);
expect(changes, 2);
});
});
}

View file

@ -0,0 +1,509 @@
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_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_import.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_learning_path_gateway.dart';
const _lesson = '''
{
"path": {
"title": "Particles",
"secondary_title": "助詞",
"description": "は, が and を",
"steps": [
{ "title": "Read", "type": "info", "content": [
{ "type": "heading", "level": 2, "text": "Topic" },
{ "type": "paragraph", "runs": [
{ "text": "は marks " },
{ "text": "the topic", "bold": true },
{ "text": ", see " },
{ "text": "the guide", "link": "https://guidetojapanese.org" },
{ "math": "x_1" } ] },
{ "type": "bulleted_list", "items": ["one", { "runs": [{ "text": "two", "italic": true }] }] },
{ "type": "image", "src": "https://example.org/x.png", "alt": "A cat" },
{ "type": "lottie", "src": "https://example.org/x.json" },
{ "type": "math", "formula": "x^2" }
] },
{ "title": "One", "type": "single_choice", "content": [],
"options": ["は", "が"], "correct_option_index": 0,
"correct_feedback": [{ "type": "paragraph", "text": "Yes." }],
"incorrect_feedback": [{ "type": "paragraph", "text": "No." }] },
{ "title": "All", "type": "multiple_choice", "content": [],
"options": ["は", "が", "を"], "correct_option_indices": [0, 2] },
{ "title": "Sort", "type": "drag_and_drop", "content": [],
"items": ["は", "が"], "targets": ["Topic", "Subject"],
"correct_pairing": { "0": [0], "1": [1] } },
{ "title": "Fill", "type": "fill_in_the_blank", "content": [],
"passage": "私___学生です。", "blanks": ["は"] }
]
}
}
''';
Map<String, Object?> paragraph(List<Map<String, Object?>> delta) => {
'type': 'paragraph',
'data': {'delta': 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);
expect(parsed.content.title, 'Particles');
expect(parsed.content.secondaryTitle, '助詞');
expect(parsed.content.description, RichContent.plain('は, が and を'));
expect(
[for (final step in parsed.steps) step.kind],
[
StepKind.info,
StepKind.singleChoice,
StepKind.multipleChoice,
StepKind.dragAndDrop,
StepKind.fillInTheBlank,
],
);
expect(
parsed.steps[1].content.exercise,
const SingleChoice(options: ['は', 'が'], correct: 0),
);
expect(parsed.steps[1].content.rightFeedback, RichContent.plain('Yes.'));
expect(
parsed.steps[2].content.exercise,
const MultipleChoice(options: ['は', 'が', 'を'], correct: {0, 2}),
);
expect(
parsed.steps[3].content.exercise,
const DragAndDrop(
targets: ['Topic', 'Subject'],
items: [
DragItem(text: 'は', target: 0),
DragItem(text: 'が', target: 1),
],
),
);
expect(
parsed.steps[4].content.exercise,
const FillInTheBlank(passage: '私___学生です。', blanks: ['は']),
);
});
test('turns text blocks into rich text, and leaves out what it would '
'fetch from the web', () {
final parsed = parsePathImport(_lesson);
final document =
parsed.steps.first.content.body.editorJson['document']!
as Map<String, Object?>;
expect(document['children'], [
{
'type': 'heading',
'data': {
'level': 2,
'delta': [
{'insert': 'Topic'},
],
},
},
paragraph([
{'insert': 'は marks '},
{
'insert': 'the topic',
'attributes': {'bold': true},
},
{'insert': ', see '},
{
'insert': 'the guide',
'attributes': {'href': 'https://guidetojapanese.org'},
},
{
'insert': 'x_1',
'attributes': {inlineMathKey: true},
},
]),
{
'type': 'bulleted_list',
'data': {
'delta': [
{'insert': 'one'},
],
},
},
{
'type': 'bulleted_list',
'data': {
'delta': [
{
'insert': 'two',
'attributes': {'italic': true},
},
],
},
},
// The image's caption stays; the image and the animation don't.
paragraph([
{
'insert': 'A cat',
'attributes': {'italic': true},
},
]),
{
'type': mathBlockType,
'data': {mathFormulaKey: 'x^2'},
},
]);
expect(parsed.skippedBlocks, 2);
});
test('reads tables, callouts, spoilers, quotes, dividers, and text with '
'readings, colours, underlines and strikes', () {
final parsed = parsePathImport(r'''
{"path": {"title": "x", "steps": [{"title": "a", "type": "info", "content": [
{"type": "paragraph", "runs": [
{"text": "学生", "ruby": " がくせい "},
{"text": "だ", "color": "#E8590C"},
{"text": "い", "strike": true, "underline": true},
{"text": "\n", "ruby": " "}]},
{"type": "table", "rows": [["", {"runs": [{"text": "Positive", "bold": true}]}],
["Past", "学生だった"]]},
{"type": "callout", "kind": "info", "title": " Summary ", "content": [
{"type": "numbered_list", "items": ["Attach だ"]}]},
{"type": "callout", "content": []},
{"type": "spoiler", "text": "これだ"},
{"type": "quote", "text": "A: 元気?"},
{"type": "divider"}
]}]}}''');
final document =
parsed.steps.single.content.body.editorJson['document']!
as Map<String, Object?>;
expect(document['children'], [
paragraph([
{
'insert': '学生',
'attributes': {rubyKey: 'がくせい'},
},
{
'insert': 'だ',
'attributes': {'font_color': '0xffe8590c'},
},
{
'insert': 'い',
'attributes': {'underline': true, 'strikethrough': true},
},
// A reading of nothing but spaces is none.
{'insert': '\n'},
]),
{
'type': 'table',
'data': {
'colsLen': 2,
'rowsLen': 2,
'colDefaultWidth': 160.0,
'rowDefaultHeight': 40.0,
'colMinimumWidth': 40.0,
},
// By column, then row, as the editor keeps them.
'children': [
for (final (column, row, delta) in [
(0, 0, <Object?>[]),
(
0,
1,
[
{'insert': 'Past'},
],
),
(
1,
0,
[
{
'insert': 'Positive',
'attributes': {'bold': true},
},
],
),
(
1,
1,
[
{'insert': '学生だった'},
],
),
])
{
'type': 'table/cell',
'data': {
'colPosition': column,
'rowPosition': row,
// Each column as wide as its longest line: "Past", four
// narrow letters; "学生だった", five wide ones.
'width': [62.0, 104.0][column],
},
'children': [paragraph(delta.cast())],
},
],
},
{
'type': calloutBlockType,
'data': {calloutKindKey: 'info', calloutTitleKey: 'Summary'},
'children': [
{
'type': 'numbered_list',
'data': {
'delta': [
{'insert': 'Attach だ'},
],
},
},
],
},
// A note, with a line to write in.
{
'type': calloutBlockType,
'data': {calloutKindKey: 'note'},
'children': [paragraph([])],
},
{
'type': spoilerBlockType,
'data': {
'delta': [
{'insert': 'これだ'},
],
},
},
{
'type': 'quote',
'data': {
'delta': [
{'insert': 'A: 元気?'},
],
},
},
{'type': 'divider'},
]);
});
test('narrows a table wider than a phone, every column alike', () {
final parsed = parsePathImport(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
'"content": [{"type": "table", "rows": [["", "Positive", "", '
'"Negative", ""], ["Non-Past", "学生(だ)", "Is student", '
'"学生じゃない", "Is not student"]]}]}]}}',
);
final document =
parsed.steps.single.content.body.editorJson['document']!
as Map<String, Object?>;
final table =
(document['children']! as List<Object?>).single!
as Map<String, Object?>;
final widths = {
for (final cell
in (table['children']! as List<Object?>).cast<Map<String, Object?>>())
(cell['data']! as Map<String, Object?>)['colPosition']:
(cell['data']! as Map<String, Object?>)['width']! as double,
};
expect(widths.values.reduce((a, b) => a + b), lessThanOrEqualTo(314));
expect(widths.values, everyElement(greaterThanOrEqualTo(48)));
// The widest column stays the widest.
expect(widths[4], widths.values.reduce(max));
});
test("reads a step's sources: their parts, \"citation\" as what they are "
'part of, and text as a citation to keep as it is', () {
final parsed = parsePathImport('''
{"path": {"title": "x", "steps": [{"title": "a", "type": "info", "sources": [
{"authors": "Tae Kim", "year": 2014, "title": "A Guide",
"citation": "Online", "isbn": "978-1", "url": "https://guidetojapanese.org"},
{"title": " Genki ", "publisher": "The Japan Times"},
"Banno, E. (2020). Genki I.",
{}
]}]}}''');
expect(parsed.steps.single.content.sources, const [
Source(
authors: 'Tae Kim',
year: '2014',
title: 'A Guide',
container: 'Online',
isbn: '978-1',
url: 'https://guidetojapanese.org',
),
Source(title: 'Genki', publisher: 'The Japan Times'),
Source(raw: 'Banno, E. (2020). Genki I.'),
]);
});
group('refuses, saying why,', () {
void refuses(String source, PathImportProblem problem, {int? step}) =>
expect(
() => parsePathImport(source),
throwsA(
isA<PathImportException>()
.having((e) => e.problem, 'problem', problem)
.having((e) => e.step, 'step', step),
),
);
test('what is no JSON', () => refuses('{', PathImportProblem.notJson));
test('a path without a title', () {
refuses('{"path": {"steps": []}}', PathImportProblem.noTitle);
});
test('a step of an unknown type', () {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "quiz"}]}}',
PathImportProblem.unknownStepType,
step: 1,
);
});
test('a right option that is not there', () {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", '
'"type": "single_choice", "options": ["a", "b"], '
'"correct_option_index": 5}]}}',
PathImportProblem.rightOptionMissing,
step: 1,
);
});
test('a source that is neither an object nor text, or has a part that '
'is no text', () {
for (final source in ['3', '{"title": true}']) {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
'"sources": [$source]}]}}',
PathImportProblem.malformedSource,
step: 1,
);
}
});
test('a table whose rows differ in length, or without rows', () {
for (final rows in ['[["a", "b"], ["c"]]', '[]', '[[{"text": "a"}]]']) {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
'"content": [{"type": "table", "rows": $rows}]}]}}',
PathImportProblem.malformedTable,
step: 1,
);
}
});
test('a callout of an unknown kind, or with a title that is no text', () {
for (final callout in ['"kind": "aside"', '"title": 3']) {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
'"content": [{"type": "callout", $callout}]}]}}',
PathImportProblem.malformedCallout,
step: 1,
);
}
});
test('a table or a callout inside a callout', () {
for (final block in [
'{"type": "table", "rows": [["a"]]}',
'{"type": "callout"}',
]) {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
'"content": [{"type": "callout", "content": [$block]}]}]}}',
PathImportProblem.notInCallout,
step: 1,
);
}
});
test('a colour that is not #rrggbb, or a reading that is no text', () {
for (final run in ['"color": "orange"', '"color": "#fff"', '"ruby": 1']) {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
'"content": [{"type": "paragraph", "runs": [{"text": "a", $run}]}]}]}}',
PathImportProblem.malformedRun,
step: 1,
);
}
});
test('blanks that do not fit the gaps', () {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", '
'"type": "fill_in_the_blank", "passage": "a ___ b ___", '
'"blanks": ["x"]}]}}',
PathImportProblem.blanksMismatch,
step: 1,
);
});
});
test("says what is wrong in the app's language", () {
const atStep = PathImportException(
PathImportProblem.unknownStepType,
step: 2,
detail: 'quiz',
);
const ofPath = PathImportException(PathImportProblem.noTitle);
final english = lookupAppLocalizations(const Locale('en', 'GB'));
final german = lookupAppLocalizations(const Locale('de'));
expect(
english.pathImportProblemText(atStep),
'Step 2: unknown "type": quiz.',
);
expect(english.pathImportProblemText(ofPath), 'The path has no "title".');
expect(
german.pathImportProblemText(atStep),
'Schritt 2: unbekannter „type“: quiz.',
);
expect(
german.pathImportProblemText(ofPath),
'Der Pfad hat keinen „title“.',
);
});
test('imports a path with its steps into a studyset', () async {
final paths = LearningPathRepository(FakeLearningPathGateway());
addTearDown(paths.dispose);
final imported = await PathImporter(paths).import('japanese', _lesson);
expect(imported.steps, 5);
expect(imported.skippedBlocks, 2);
final stored = await paths.stepsOf(imported.pathId);
expect(
[for (final step in stored) step.content.title],
['Read', 'One', 'All', 'Sort', 'Fill'],
);
expect((await paths.pathsOf('japanese')).single.content.title, 'Particles');
});
}

View file

@ -0,0 +1,108 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.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/answer.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
import '../../../support/fake_path_answer_gateway.dart';
void main() {
late FakePathAnswerGateway gateway;
late PathProgressRepository progress;
setUp(() {
gateway = FakePathAnswerGateway();
progress = PathProgressRepository(gateway);
});
tearDown(() => progress.dispose());
const kinds = {
's1': StepKind.singleChoice,
's2': StepKind.info,
's3': StepKind.fillInTheBlank,
};
test('records answers, and a step is done once answered right', () async {
await progress.record(
's1',
AnswerOutcome.wrong,
const SingleChoiceAnswer(1),
);
expect((await progress.of(kinds))['s1']?.done ?? false, isFalse);
await progress.record(
's1',
AnswerOutcome.right,
const SingleChoiceAnswer(0),
);
await progress.record('s2', AnswerOutcome.continued);
final now = await progress.of(kinds);
expect(now['s1']?.answer, const SingleChoiceAnswer(0));
expect(now['s2']?.done, isTrue);
expect(now['s3']?.done ?? false, isFalse);
});
test('a reset starts the steps afresh, and undoing it brings their '
'progress back', () async {
await progress.record(
's1',
AnswerOutcome.right,
const SingleChoiceAnswer(0),
);
await progress.record('s2', AnswerOutcome.continued);
final at = await withClock(
Clock.fixed(DateTime.utc(2030)),
() => progress.reset({'s1', 's2'}),
);
expect((await progress.of(kinds)).values.any((p) => p.done), isFalse);
await progress.undoReset({'s1', 's2'}, at);
expect((await progress.of(kinds))['s1']?.done, isTrue);
});
test('an answer this app cannot read still counts, without being '
'shown', () async {
gateway.receiveRemote((
id: 'answer-9000',
stepId: 's1',
answeredAt: DateTime.utc(2026, 10, 3),
outcome: 'right',
answer: '{"hologram": true}',
));
final now = await progress.of(kinds);
expect(now['s1']?.done, isTrue);
expect(now['s1']?.answer, isNull);
});
test('says on which local days steps were done', () async {
await withClock(
Clock.fixed(DateTime(2026, 10, 3, 9)),
() => progress.record('s2', AnswerOutcome.continued),
);
await withClock(
Clock.fixed(DateTime(2026, 10, 3, 20)),
() => progress.record(
's1',
AnswerOutcome.right,
const SingleChoiceAnswer(0),
),
);
await withClock(
Clock.fixed(DateTime(2026, 10, 4, 9)),
() => progress.record(
's1',
AnswerOutcome.wrong,
const SingleChoiceAnswer(1),
),
);
expect(await progress.doneTimes(), [
DateTime(2026, 10, 3, 9).toUtc(),
DateTime(2026, 10, 3, 20).toUtc(),
]);
});
}

View file

@ -0,0 +1,182 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/answer.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/step_content.dart';
void main() {
ByteData bytes(Object json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
group('A path', () {
test('survives a round trip, with and without a secondary title', () {
final full = LearningPathContent(
title: 'Particles',
secondaryTitle: '助詞',
description: RichContent.plain('は, が and を'),
);
final plain = LearningPathContent.titled('Verbs');
expect(decodeLearningPathPayload(encodeLearningPathPayload(full)), full);
expect(
decodeLearningPathPayload(encodeLearningPathPayload(plain)),
plain,
);
});
test('refuses a newer schema and a blanked payload', () {
expect(
() => decodeLearningPathPayload(
bytes({
'schemaVersion': learningPathSchemaVersion + 1,
'title': 'x',
'description': RichContent.empty().toJson(),
}),
),
throwsFormatException,
);
expect(
() => decodeLearningPathPayload(redactedPayload),
throwsFormatException,
);
});
});
group('A step', () {
StepContent content(Exercise? exercise) => StepContent(
title: 'は or が?',
body: RichContent.plain('Which particle marks the topic?'),
exercise: exercise,
rightFeedback: RichContent.plain('Yes: は marks the topic.'),
wrongFeedback: RichContent.plain('Look at the topic again.'),
sources: const [
Source(
authors: 'Tae Kim',
year: '2014',
title: 'A Guide to Japanese Grammar',
container: 'Self-published',
isbn: '978-1-4499-5422-3',
url: 'https://guidetojapanese.org',
),
Source(title: 'Class notes'),
Source(raw: 'Kim, T. (2014). A Guide to Japanese Grammar.'),
],
);
final exercises = {
StepKind.info: null,
StepKind.singleChoice: const SingleChoice(
options: ['は', 'が'],
correct: 0,
),
StepKind.multipleChoice: const MultipleChoice(
options: ['は', 'が', 'を'],
correct: {2, 0},
),
StepKind.dragAndDrop: const DragAndDrop(
targets: ['Topic', 'Subject'],
items: [
DragItem(text: 'は', target: 0),
DragItem(text: 'が', target: 1),
],
),
StepKind.fillInTheBlank: const FillInTheBlank(
passage: '私___学生です。',
blanks: ['は'],
),
};
for (final MapEntry(key: kind, value: exercise) in exercises.entries) {
test('of kind ${kind.value} survives a round trip', () {
final original = content(exercise);
expect(
decodeStepPayload(encodeStepPayload(original), kind),
original,
);
});
}
test("refuses an exercise that is not its kind's", () {
final payload = encodeStepPayload(
content(const SingleChoice(options: ['a', 'b'], correct: 0)),
);
expect(
() => decodeStepPayload(payload, StepKind.dragAndDrop),
throwsFormatException,
);
expect(
() => decodeStepPayload(payload, StepKind.info),
throwsFormatException,
);
});
test('of schema 1 reads its sources', () {
final content = decodeStepPayload(
bytes({
'schemaVersion': 1,
'title': '',
'body': RichContent.empty().toJson(),
'rightFeedback': RichContent.empty().toJson(),
'wrongFeedback': RichContent.empty().toJson(),
'sources': [
{'authors': 'Tae Kim', 'year': '2014', 'title': 'A Guide'},
],
}),
StepKind.info,
);
expect(content.sources, const [
Source(authors: 'Tae Kim', year: '2014', title: 'A Guide'),
]);
});
test('refuses a newer schema and a blanked payload', () {
expect(
() => decodeStepPayload(
encodePayload(stepSchemaVersion + 1, {'title': ''}),
StepKind.info,
),
throwsFormatException,
);
expect(
() => decodeStepPayload(redactedPayload, StepKind.info),
throwsFormatException,
);
});
});
group('An answer', () {
final answers = {
StepKind.singleChoice: const SingleChoiceAnswer(1),
StepKind.multipleChoice: const MultipleChoiceAnswer({2, 0}),
StepKind.dragAndDrop: const DragAndDropAnswer({0: 1, 1: 0}),
StepKind.fillInTheBlank: const FillInTheBlankAnswer(['は', null]),
};
for (final MapEntry(key: kind, value: answer) in answers.entries) {
test('to a step of kind ${kind.value} survives a round trip', () {
expect(decodeAnswer(kind, encodeAnswer(answer)), answer);
});
}
test('refuses an answer of another kind', () {
expect(
() => decodeAnswer(
StepKind.dragAndDrop,
encodeAnswer(const SingleChoiceAnswer(0)),
),
throwsFormatException,
);
});
});
}

View file

@ -0,0 +1,156 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/answer.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
void main() {
group('Single choice', () {
const exercise = SingleChoice(options: ['は', 'が', 'を'], correct: 0);
test('the right option is right', () {
expect(grade(exercise, const SingleChoiceAnswer(0)).right, isTrue);
});
test('a wrong option is wrong, and only it shows as such', () {
expect(
grade(exercise, const SingleChoiceAnswer(2)),
const Grade(right: false, wrongPicks: {2}),
);
});
});
group('Multiple choice', () {
const exercise = MultipleChoice(
options: ['は', 'が', 'を', 'に'],
correct: {0, 1},
);
test('every right option and no other is right', () {
expect(
grade(exercise, const MultipleChoiceAnswer({0, 1})).right,
isTrue,
);
});
test('a missing right option is wrong without revealing it', () {
expect(
grade(exercise, const MultipleChoiceAnswer({0})),
const Grade(right: false),
);
});
test('a wrong pick is wrong, and shows as such', () {
expect(
grade(exercise, const MultipleChoiceAnswer({0, 1, 3})),
const Grade(right: false, wrongPicks: {3}),
);
});
});
group('Drag and drop', () {
const exercise = DragAndDrop(
targets: ['Topic', 'Subject'],
items: [
DragItem(text: 'は', target: 0),
DragItem(text: 'が', target: 1),
DragItem(text: 'も', target: 0),
],
);
test('every item in its category is right', () {
expect(
grade(exercise, const DragAndDropAnswer({0: 0, 1: 1, 2: 0})).right,
isTrue,
);
});
test('an item left out is wrong, but not shown as wrong', () {
expect(
grade(exercise, const DragAndDropAnswer({0: 0, 1: 1})),
const Grade(right: false),
);
});
test('an item in the wrong category shows as wrong', () {
expect(
grade(exercise, const DragAndDropAnswer({0: 1, 1: 1, 2: 0})),
const Grade(right: false, wrongPicks: {0}),
);
});
});
group('Fill in the blank', () {
const exercise = FillInTheBlank(
passage: '私___学生です。猫___好きです。',
blanks: ['は', 'が'],
);
test('knows its gaps and the text around them', () {
expect(exercise.gaps, 2);
expect(exercise.parts, ['私', '学生です。猫', '好きです。']);
expect(exercise.isComplete, isTrue);
});
test('the right word in every gap is right', () {
expect(
grade(exercise, const FillInTheBlankAnswer(['は', 'が'])).right,
isTrue,
);
});
test('a gap left empty is wrong, but not shown as wrong', () {
expect(
grade(exercise, const FillInTheBlankAnswer(['は', null])),
const Grade(right: false),
);
});
test('a wrong word shows as wrong', () {
expect(
grade(exercise, const FillInTheBlankAnswer(['が', 'は'])),
const Grade(right: false, wrongPicks: {0, 1}),
);
});
});
test('an answer of another kind is wrong', () {
expect(
grade(
const SingleChoice(options: ['a', 'b'], correct: 0),
const MultipleChoiceAnswer({0}),
).right,
isFalse,
);
});
group('An exercise is complete', () {
test('with two options and the right one chosen', () {
expect(const SingleChoice(options: ['a', 'b']).isComplete, isFalse);
expect(
const SingleChoice(options: ['a', ''], correct: 0).isComplete,
isFalse,
);
expect(
const SingleChoice(options: ['a', 'b'], correct: 1).isComplete,
isTrue,
);
});
test('with a word for every gap', () {
expect(
const FillInTheBlank(passage: 'a ___ b ___', blanks: ['x']).isComplete,
isFalse,
);
expect(const FillInTheBlank(passage: 'no gaps').isComplete, isFalse);
});
test('with two categories and every item in one', () {
expect(
const DragAndDrop(
targets: ['a', 'b'],
items: [DragItem(text: 'x', target: 2)],
).isComplete,
isFalse,
);
});
});
}

View file

@ -0,0 +1,88 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/answer.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
void main() {
var next = 0;
StepAnswer answer(
String step,
int minute,
AnswerOutcome outcome, [
ExerciseAnswer? answer,
]) => StepAnswer(
id: 'a-${(next++).toString().padLeft(4, '0')}',
stepId: step,
at: DateTime.utc(2026, 10, 3, 9, minute),
outcome: outcome,
answer: answer,
);
test('a step answered wrong, then right, is done with the right '
'answer', () {
final progress = progressOf([
answer('s1', 0, AnswerOutcome.wrong, const SingleChoiceAnswer(2)),
answer('s1', 1, AnswerOutcome.right, const SingleChoiceAnswer(0)),
]);
expect(
progress['s1'],
StepProgress(
done: true,
answer: const SingleChoiceAnswer(0),
doneAt: DateTime.utc(2026, 10, 3, 9, 1),
),
);
});
test('an info step passed is done', () {
expect(
progressOf([answer('s1', 0, AnswerOutcome.continued)])['s1']!.done,
isTrue,
);
});
test('only wrong answers leave a step undone', () {
expect(
progressOf([answer('s1', 0, AnswerOutcome.wrong)])['s1']!.done,
isFalse,
);
});
test('a fresh start undoes what came before, not what follows', () {
expect(
progressOf([
answer('s1', 0, AnswerOutcome.right),
answer('s1', 1, AnswerOutcome.reset),
])['s1']!.done,
isFalse,
);
expect(
progressOf([
answer('s2', 0, AnswerOutcome.right),
answer('s2', 1, AnswerOutcome.reset),
answer('s2', 2, AnswerOutcome.continued),
])['s2']!.done,
isTrue,
);
});
test('answers from two devices count in time order, whatever order they '
'arrive in', () {
final progress = progressOf([
answer('s1', 5, AnswerOutcome.reset),
answer('s1', 3, AnswerOutcome.right),
]);
expect(progress['s1']!.done, isFalse);
});
test('keeps every step apart', () {
final progress = progressOf([
answer('s1', 0, AnswerOutcome.right),
answer('s2', 0, AnswerOutcome.wrong),
]);
expect(progress['s1']!.done, isTrue);
expect(progress['s2']!.done, isFalse);
});
}

View file

@ -0,0 +1,59 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
void main() {
StepContent step(String title, Exercise exercise) =>
StepContent.empty(StepKind.singleChoice)
.withField(StepField.title, title)
.withField(StepField.exercise, exercise);
StepRevision revision(
String id,
StepContent content, {
Set<String> parents = const {},
}) => StepRevision(id: id, parents: parents, content: content);
const before = SingleChoice(options: ['は', 'が'], correct: 0);
const fixed = SingleChoice(options: ['は', 'が'], correct: 1);
const longer = SingleChoice(options: ['は', 'が', 'を'], correct: 0);
test('a title changed on one device and the exercise on another both '
'survive', () {
final merged = mergeStepRevisions([
revision('r1', step('Particles', before)),
revision('r2', step('は or が?', before), parents: {'r1'}),
revision('r3', step('Particles', fixed), parents: {'r1'}),
])!;
expect(merged.content, step('は or が?', fixed));
expect(merged.conflicts, isEmpty);
});
test('an exercise changed differently on two devices is a conflict, '
'whole', () {
final merged = mergeStepRevisions([
revision('r1', step('Particles', before)),
revision('r2', step('Particles', fixed), parents: {'r1'}),
revision('r3', step('Particles', longer), parents: {'r1'}),
])!;
expect(merged.conflicts, {
StepField.exercise: [fixed, longer],
});
});
test('a new step of a kind starts with that kind of exercise', () {
expect(StepContent.empty(StepKind.info).exercise, isNull);
expect(
StepContent.empty(StepKind.dragAndDrop).exercise,
isA<DragAndDrop>(),
);
expect(
StepContent.empty(StepKind.fillInTheBlank).body,
RichContent.empty(),
);
});
}

View file

@ -0,0 +1,932 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
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/citation/model/source.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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/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/learning_path/widget/confetti_burst.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/studyset/bloc/content_tab_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_file_picker.dart';
import '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_path_answer_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_studyset_tab_store.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeStudysetGateway studysets;
late FakeLearningPathGateway pathGateway;
late FakePathAnswerGateway answers;
late FakeFilePicker files;
late FakeNoteGateway noteGateway;
setUp(() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
);
pathGateway = FakeLearningPathGateway();
answers = FakePathAnswerGateway();
files = FakeFilePicker();
noteGateway = FakeNoteGateway();
});
Future<void> showApp(
WidgetTester tester,
String location, {
DeviceSettings deviceSettings = const DeviceSettings(),
Size size = const Size(1200, 1000),
StudysetTabStore? tabs,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
pathGateway: pathGateway,
answerGateway: answers,
noteGateway: noteGateway,
files: files,
deviceSettings: deviceSettings,
studysetTabStore: tabs,
),
location: location,
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
/// A menu item acts in the frame after its menu closes.
Future<void> chooseInMenu(WidgetTester tester, String item) async {
await tester.tap(find.text(item));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
LearningPathRepository repository() {
final paths = LearningPathRepository(pathGateway);
addTearDown(paths.dispose);
return paths;
}
StepContent step(StepKind kind, String title, [Exercise? exercise]) {
final content = StepContent.empty(kind)
.withField(StepField.title, title)
.withField(StepField.body, RichContent.plain('$title body'));
return exercise == null
? content
: content.withField(StepField.exercise, exercise);
}
testWidgets('the studyset opens on its paths while one is unfinished', (
tester,
) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.studyset('japanese'));
expect(find.text('Particles'), findsOneWidget);
});
testWidgets('a studyset opens again on the part it was left on, here, '
'even with a path unfinished; a tab kept before there were parts '
'counts as its part', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(
tester,
RoutePaths.studyset('japanese'),
tabs: MemoryStudysetTabStore({'japanese': 'notes'}),
);
expect(shownPart(tester), StudysetPart.material);
});
testWidgets('the part a studyset shows, chosen on opening or by the '
'person, is kept for the next time', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
final tabs = MemoryStudysetTabStore();
await showApp(tester, RoutePaths.studyset('japanese'), tabs: tabs);
expect(shownPart(tester), StudysetPart.plan);
expect(tabs.kept, {'japanese': 'plan'});
await openPart(tester, 'Material');
// The material's tab, which says the part too: the paths, the first
// with something in it.
expect(tabs.kept, {'japanese': 'paths'});
await tester.tap(find.widgetWithText(Tab, 'Notes'));
await tester.pumpAndSettle();
expect(tabs.kept, {'japanese': 'notes'});
await openPart(tester, 'Study plan');
expect(tabs.kept, {'japanese': 'plan'});
});
testWidgets('the study plan names the next step of each path under way, '
'and how far every path has come; a tap opens the path', (
tester,
) async {
final paths = repository();
final particles = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
final read = await paths.createStep(
particles,
StepKind.info,
step(StepKind.info, 'Read'),
);
await paths.createStep(
particles,
StepKind.info,
step(StepKind.info, 'は and が'),
);
await paths.createStep(
particles,
StepKind.info,
step(StepKind.info, 'More'),
);
final verbs = await paths.createPath(
'japanese',
LearningPathContent.titled('Verbs'),
);
await paths.createStep(verbs, StepKind.info, step(StepKind.info, 'Groups'));
final progress = PathProgressRepository(answers);
addTearDown(progress.dispose);
await progress.record(read.stepId, AnswerOutcome.continued);
await showApp(tester, RoutePaths.studyset('japanese'));
expect(shownPart(tester), StudysetPart.plan);
// The path under way, not the one not begun yet.
expect(find.text('Continue “Particles”'), findsOneWidget);
expect(find.text('Step 2 of 3: は and が'), findsOneWidget);
expect(find.text('Start “Verbs”'), findsNothing);
expect(find.text('1 of 3 steps'), findsOneWidget);
expect(find.text('0 of 1 step'), findsOneWidget);
// Managing the paths is the material's.
expect(find.byTooltip('Path options'), findsNothing);
await tester.tap(find.text('Verbs'));
await tester.pumpAndSettle();
expect(find.byType(PathRunnerScreen), findsOneWidget);
});
testWidgets('with no path under way, the study plan offers to start the '
'first one not finished', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.studyset('japanese'));
expect(find.text('Start “Particles”'), findsOneWidget);
expect(find.text('Step 1 of 1: Read'), findsOneWidget);
});
testWidgets('a studyset with only notes opens on them', (tester) async {
final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose);
await notes.create(
'japanese',
NoteContent(title: 'Particles', body: RichContent.empty()),
);
await showApp(tester, RoutePaths.studyset('japanese'));
expect(shownPart(tester), StudysetPart.material);
expect(find.text('Particles'), findsOneWidget);
});
testWidgets("notes only in its topics don't open the studyset on its own "
'empty notes', (tester) async {
studysets.receiveRemoteChange(
'grammar',
encodeStudysetPayload(StudysetContent.titled('Grammar')),
parentId: 'japanese',
);
final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose);
await notes.create(
'grammar',
NoteContent(title: 'Particles', body: RichContent.empty()),
);
await showApp(tester, RoutePaths.studyset('japanese'));
expect(shownPart(tester), StudysetPart.material);
});
testWidgets('without paths, the studyset opens on its flashcards', (
tester,
) async {
await showApp(tester, RoutePaths.studyset('japanese'));
expect(find.text('No flashcards yet'), findsOneWidget);
await tester.tap(find.widgetWithText(Tab, 'Paths'));
await tester.pumpAndSettle();
expect(find.text('No paths yet'), findsOneWidget);
await openPart(tester, 'Study plan');
expect(find.text('No learning paths yet'), findsOneWidget);
});
testWidgets('Create a path asks for a title, then opens the path to write; '
'a step added opens too, and leaving it saves it', (tester) async {
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
await chooseInMenu(tester, 'Create a path');
await tester.enterText(find.byType(TextField).first, 'Particles');
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Create'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Edit path'), findsOneWidget);
await tester.tap(find.text('Add step'));
await tester.pumpAndSettle();
await chooseInMenu(tester, 'Single choice');
expect(find.text('Edit step · Single choice'), findsOneWidget);
await tester.enterText(find.byType(TextFormField).first, 'Which one?');
await tester.pump();
await tester.pageBack();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Edit path'), findsOneWidget);
expect(find.text('Which one?'), findsOneWidget);
});
testWidgets("the path editor groups the path's details in one card and its "
'steps in another, filling the window below', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.editPath('japanese', id));
final cards = find.byType(SectionCard);
expect(cards, findsNWidgets(2));
Finder inCard(int index, Finder part) =>
find.descendant(of: cards.at(index), matching: part);
for (final part in ['Particles', 'Description', 'Add a description']) {
expect(inCard(0, find.text(part)), findsOneWidget, reason: part);
}
expect(inCard(0, find.widgetWithText(ActionChip, 'Add tags')), findsOne);
for (final part in ['Steps', 'Add step', 'Read']) {
expect(inCard(1, find.text(part)), findsOneWidget, reason: part);
}
// 12 above the screen's foot (inside the shell's panel).
final screen = find
.ancestor(of: cards.at(1), matching: find.byType(Scaffold))
.first;
expect(
tester.getRect(cards.at(1)).bottom,
moreOrLessEquals(tester.getRect(screen).bottom - 12, epsilon: 1),
);
});
testWidgets('imports a path from a file, saying what came in and what was '
'left out', (tester) async {
files.next = (
name: 'particles.json',
bytes: utf8.encode(
'{"path": {"title": "Particles", "steps": ['
'{"title": "Read", "type": "info", "content": ['
'{"type": "paragraph", "text": "は marks the topic"},'
'{"type": "image", "src": "https://example.org/x.png"}]}]}}',
),
);
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(
find.descendant(
of: find.byType(AppBar),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Import content'));
await tester.pumpAndSettle();
await tester.tap(find.text('Learning paths'));
await tester.pumpAndSettle();
expect(files.asked.single, ['json']);
expect(
find.textContaining('Imported "Particles" with 1 step.'),
findsOneWidget,
);
expect(find.textContaining('1 block was left out'), findsOneWidget);
// The paths show by themselves.
expect(shownPart(tester), StudysetPart.material);
expect(find.text('Particles'), findsOneWidget);
});
testWidgets("imports several paths at once, in the order of their files' "
'names', (tester) async {
Uint8List path(String title) => utf8.encode(
'{"path": {"title": "$title", "steps": ['
'{"title": "Read", "type": "info"}]}}',
);
files.nextMany = [
(name: '02-particles.json', bytes: path('Particles')),
(name: '01-state-of-being.json', bytes: path('State-of-being')),
];
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(
find.descendant(
of: find.byType(AppBar),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Import content'));
await tester.pumpAndSettle();
await tester.tap(find.text('Learning paths'));
await tester.pumpAndSettle();
expect(
find.textContaining('Imported 2 paths with 2 steps.'),
findsOneWidget,
);
expect(
tester.getTopLeft(find.text('State-of-being')).dy,
lessThan(tester.getTopLeft(find.text('Particles')).dy),
);
});
testWidgets('an import shows the paths at once, and how far it has come '
'above those it adds', (tester) async {
Uint8List path(String title) => utf8.encode(
'{"path": {"title": "$title", "steps": ['
'{"title": "Read", "type": "info"}]}}',
);
files.nextMany = [
(name: '01.json', bytes: path('State-of-being')),
(name: '02.json', bytes: path('Particles')),
];
final hold = Completer<void>();
pathGateway.holdSteps = hold;
await showApp(tester, RoutePaths.studyset('japanese'));
expect(shownPart(tester), StudysetPart.material);
await tester.tap(
find.descendant(
of: find.byType(AppBar),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Import content'));
await tester.pumpAndSettle();
await tester.tap(find.text('Learning paths'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(shownPart(tester), StudysetPart.material);
expect(
find.text('Importing path 1 of 2: “State-of-being”…'),
findsOneWidget,
);
expect(find.byType(LinearProgressIndicator), findsOneWidget);
pathGateway.holdSteps = null;
hold.complete();
await tester.pumpAndSettle();
expect(find.byType(LinearProgressIndicator), findsNothing);
expect(
find.textContaining('Imported 2 paths with 2 steps.'),
findsOneWidget,
);
});
testWidgets('a note written from Add material shows in the material on '
'coming back, as flashcards do', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.studyset('japanese'));
expect(shownPart(tester), StudysetPart.plan);
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
await chooseInMenu(tester, 'Create a note');
await tester.enterText(find.byType(TextField), 'Topic or subject');
await tester.pump();
await tester.pageBack();
await tester.pumpAndSettle();
expect(shownPart(tester), StudysetPart.material);
expect(find.text('Topic or subject'), findsOneWidget);
});
testWidgets('works through a path: an info step, a choice answered wrong '
'then right, and the finish', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await paths.createStep(
id,
StepKind.singleChoice,
step(
StepKind.singleChoice,
'Which marks the topic?',
const SingleChoice(options: ['は', 'が'], correct: 0),
),
);
await showApp(tester, RoutePaths.path('japanese', id));
expect(find.text('Read'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Continue'));
await tester.pumpAndSettle();
expect(find.text('Which marks the topic?'), findsOneWidget);
await tester.tap(find.text('が'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Check answers'));
await tester.pumpAndSettle();
expect(find.text('Not quite'), findsOneWidget);
await tester.tap(find.text('は'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Check answers'));
await tester.pumpAndSettle();
expect(find.text('Correct!'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Finish'));
await tester.pumpAndSettle();
expect(find.text('You finished this path'), findsOneWidget);
expect(find.text('2 steps complete.'), findsOneWidget);
});
group('finishing a path', () {
final confetti = find.descendant(
of: find.byType(ConfettiBurst),
matching: find.byType(CustomPaint),
);
Future<void> finish(WidgetTester tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.path('japanese', id));
await tester.tap(find.widgetWithText(FilledButton, 'Finish'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('You finished this path'), findsOneWidget);
}
testWidgets('bursts confetti once, which then clears', (tester) async {
await finish(tester);
expect(confetti, findsOneWidget);
// It ends by itself.
await tester.pumpAndSettle();
expect(confetti, findsNothing);
});
testWidgets('bursts no confetti with animations switched off on the '
'device', (tester) async {
tester.platformDispatcher.accessibilityFeaturesTestValue =
const FakeAccessibilityFeatures(disableAnimations: true);
addTearDown(
tester.platformDispatcher.clearAccessibilityFeaturesTestValue,
);
await finish(tester);
expect(confetti, findsNothing);
});
});
testWidgets('on a phone, the way on is one wide button in the middle at '
'the bottom, in the same place on every step; Back beside it once '
'there is a step behind', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Listen'));
await showApp(
tester,
RoutePaths.path('japanese', id),
size: const Size(412, 892),
);
Rect onward(String label) =>
tester.getRect(find.widgetWithText(FilledButton, label));
final first = onward('Continue');
expect(first.center.dx, moreOrLessEquals(206));
expect(first.width, greaterThan(240));
expect(first.top, greaterThan(892 * 0.75));
expect(find.byTooltip('Back'), findsNothing);
await tester.tap(find.widgetWithText(FilledButton, 'Continue'));
await tester.pumpAndSettle();
expect(find.text('Listen'), findsOneWidget);
expect(onward('Finish'), first);
expect(
tester.getRect(find.byTooltip('Back')).right,
lessThan(first.left),
);
});
testWidgets("the path's options at the top: Reset progress starts it "
'afresh at its first step, with an Undo', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Listen'));
await showApp(tester, RoutePaths.path('japanese', id));
await tester.tap(find.widgetWithText(FilledButton, 'Continue'));
await tester.pumpAndSettle();
expect(find.text('Listen'), findsOneWidget);
await tester.tap(find.byTooltip('Path options'));
await tester.pumpAndSettle();
await chooseInMenu(tester, 'Reset progress');
expect(find.text('Reset progress?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Reset'));
await tester.pumpAndSettle();
expect(find.text('Read'), findsOneWidget);
expect(find.text('Progress reset'), findsOneWidget);
expect(find.text('Undo'), findsOneWidget);
});
testWidgets("the path's options at the top: Delete asks, closes the path "
'and takes it from the list; Undo brings it back', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Particles'));
await tester.pumpAndSettle();
expect(find.text('Read'), findsOneWidget);
await tester.tap(find.byTooltip('Path options'));
await tester.pumpAndSettle();
await chooseInMenu(tester, 'Delete');
expect(find.text('Delete path?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(find.text('Read'), findsNothing);
expect(find.text('Path deleted'), findsOneWidget);
expect(find.text('Particles'), findsNothing);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(find.text('Particles'), findsOneWidget);
expect(pathGateway.deletions, isEmpty);
});
testWidgets("a path's More info, from its tile and from its runner, shows "
'its ids', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.studyset('japanese'));
// The paths are material; the plan only shows how far they came.
await openPart(tester, 'Material');
await tester.tap(find.byTooltip('Path options'));
await tester.pumpAndSettle();
await chooseInMenu(tester, 'More info');
expect(find.text(id), findsOneWidget);
expect(find.text('japanese'), findsOneWidget);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
await tester.tap(find.text('Particles'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Path options'));
await tester.pumpAndSettle();
await chooseInMenu(tester, 'More info');
expect(find.text(id), findsOneWidget);
});
testWidgets("a step's sources open from below its text, cited as the "
'device cites, in the order of a reference list', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(
id,
StepKind.info,
step(StepKind.info, 'Read').withField(StepField.sources, const [
Source(authors: 'Tae Kim', year: '2014', title: 'A Guide'),
Source(authors: 'Eri Banno', publisher: 'The Japan Times'),
]),
);
await showApp(
tester,
RoutePaths.path('japanese', id),
deviceSettings: const DeviceSettings(
citationStyle: CitationStyle.chicago,
),
);
await tester.tap(find.text('2 sources'));
await tester.pumpAndSettle();
final banno = find.text('Banno, Eri. The Japan Times, n.d.');
final kim = find.text('Kim, Tae. A Guide. 2014.');
expect(tester.getTopLeft(banno).dy, lessThan(tester.getTopLeft(kim).dy));
expect(find.byTooltip('Edit sources'), findsNothing);
});
testWidgets('the step editor adds a source through its sheet, and saves '
'it with the step', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
final (:stepId, revisionId: _) = await paths.createStep(
id,
StepKind.info,
step(StepKind.info, 'Read'),
);
await showApp(tester, RoutePaths.pathStep('japanese', id, stepId));
await tester.scrollUntilVisible(
find.text('Add a source'),
200,
// The step's text has an editor, a scroll view of its own.
scrollable: find.byType(Scrollable).first,
);
await tester.tap(find.text('Add a source'));
await tester.pumpAndSettle();
await tester.tap(find.text('Book or article'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Published in'),
'Nature',
);
// The step's own title field is behind the sheet.
await tester.enterText(
find.widgetWithText(TextField, 'Title').last,
'Sleep',
);
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
await tester.pumpAndSettle();
expect(find.text('Sleep. (n.d.). Nature.'), findsOneWidget);
await tester.pump(const Duration(seconds: 6));
final [saved] = await paths.stepsOf(id);
expect(saved.content.sources, const [
Source(title: 'Sleep', container: 'Nature'),
]);
});
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(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(
id,
StepKind.fillInTheBlank,
step(
StepKind.fillInTheBlank,
'Fill in',
const FillInTheBlank(passage: '私___学生です。', blanks: ['は']),
),
);
await showApp(tester, RoutePaths.path('japanese', id));
await tester.tap(find.widgetWithText(ActionChip, 'は'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Check answers'));
await tester.pumpAndSettle();
expect(find.text('Correct!'), findsOneWidget);
});
testWidgets('sorts items by tapping one and then its category', (
tester,
) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(
id,
StepKind.dragAndDrop,
step(
StepKind.dragAndDrop,
'Sort',
const DragAndDrop(
targets: ['Topic', 'Subject'],
items: [
DragItem(text: 'は', target: 0),
DragItem(text: 'が', target: 1),
],
),
),
);
await showApp(tester, RoutePaths.path('japanese', id));
await tester.tap(find.widgetWithText(ActionChip, 'は'));
await tester.pumpAndSettle();
await tester.tap(find.text('Topic'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ActionChip, 'が'));
await tester.pumpAndSettle();
await tester.tap(find.text('Subject'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Check answers'));
await tester.pumpAndSettle();
expect(find.text('Correct!'), findsOneWidget);
});
testWidgets('deleting a path asks, takes it from the list, and Undo '
'brings it back', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.studyset('japanese'));
// The paths are material; the plan only shows how far they came.
await openPart(tester, 'Material');
await tester.tap(find.byTooltip('Path options'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(find.text('Delete path?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(find.text('Path deleted'), findsOneWidget);
expect(find.text('Particles'), findsNothing);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(find.text('Particles'), findsOneWidget);
expect(pathGateway.deletions, isEmpty);
});
testWidgets('a path shows its secondary title above its title; a long '
'press opens its options, and resetting its progress asks first', (
tester,
) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles').withSecondaryTitle('Lesson 1'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.studyset('japanese'));
// The paths are material; the plan only shows how far they came.
await openPart(tester, 'Material');
expect(
tester.getRect(find.text('LESSON 1')).bottom,
lessThanOrEqualTo(tester.getRect(find.text('Particles')).top),
);
await tester.longPress(find.text('Particles'));
await tester.pumpAndSettle();
await tester.tap(find.text('Reset progress'));
await tester.pumpAndSettle();
expect(find.text('Reset progress?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Reset'));
await tester.pumpAndSettle();
expect(find.text('Progress reset'), findsOneWidget);
});
}