chore: Halkyon Learn moves into apps/learn
Its server, client, app and curator, and the tools that write its assets (maps, Anki fixtures, icons), as they are; paths are fixed in the commits that follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
11abd9afce
commit
649499be23
12 changed files with 0 additions and 0 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue