refactor: one runner for work asked again while it runs, shared editor parts
CoalescingRunner (core/async/) runs work one run at a time and once more after a run during which it was asked again; watchReads reads a repository's value so for each listener. They replace nine copies of that loop: the card, note, path and PDF watches (notes, paths and PDFs read once per change before, now a burst reads once more), the progress repository, the material and exam cubits, file uploads, and the sync service's syncs and pending count. Each now goes on after a failed run when another was asked for, which some dropped. The card browser keeps its own loop, as it emits inside the handler that started it. The card, note and step editors show conflicts through one ConflictVersions widget, and the note and step editors their saving through one EditorSaveStatus and SaveStatusLabel. TagContent, ExamProgress with examCardsToStudy, and PdfChapter move into their features' models, so widgets no longer import a payload codec, a service or the PDF engine for a type, and an archive's title is read in its importer rather than its cubit running the payload codec. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
8e3a4e56dc
commit
bb7757d7f3
7 changed files with 757 additions and 650 deletions
|
|
@ -0,0 +1,68 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/async/coalescing_runner.dart';
|
||||
|
||||
void main() {
|
||||
test('asked again while it runs, it runs once more after, however often '
|
||||
'asked; every caller waits for that too', () async {
|
||||
final log = <String>[];
|
||||
var gate = Completer<void>();
|
||||
var runs = 0;
|
||||
final runner = CoalescingRunner(() async {
|
||||
final run = ++runs;
|
||||
log.add('run $run starts');
|
||||
await gate.future;
|
||||
log.add('run $run ends');
|
||||
}, onError: (_, _) {});
|
||||
|
||||
final first = runner.run();
|
||||
final second = runner.run();
|
||||
final third = runner.run();
|
||||
await pumpEventQueue();
|
||||
expect(log, ['run 1 starts']);
|
||||
|
||||
final firstGate = gate;
|
||||
gate = Completer<void>()..complete();
|
||||
firstGate.complete();
|
||||
await Future.wait([first, second, third]);
|
||||
|
||||
expect(log, ['run 1 starts', 'run 1 ends', 'run 2 starts', 'run 2 ends']);
|
||||
expect(runs, 2);
|
||||
});
|
||||
|
||||
test('a run that fails is reported, and one asked for meanwhile still '
|
||||
'follows', () async {
|
||||
final errors = <Object>[];
|
||||
final gate = Completer<void>();
|
||||
var runs = 0;
|
||||
final runner = CoalescingRunner(() async {
|
||||
runs++;
|
||||
if (runs == 1) {
|
||||
await gate.future;
|
||||
throw StateError('disk unavailable');
|
||||
}
|
||||
}, onError: (error, _) => errors.add(error));
|
||||
|
||||
final first = runner.run();
|
||||
final again = runner.run();
|
||||
gate.complete();
|
||||
await Future.wait([first, again]);
|
||||
|
||||
expect(errors, [isA<StateError>()]);
|
||||
expect(runs, 2);
|
||||
});
|
||||
|
||||
test('idle completes once nothing runs; afterwards a run starts '
|
||||
'afresh', () async {
|
||||
var runs = 0;
|
||||
final runner = CoalescingRunner(() async => runs++, onError: (_, _) {});
|
||||
await runner.idle;
|
||||
|
||||
unawaited(runner.run());
|
||||
await runner.idle;
|
||||
await runner.run();
|
||||
|
||||
expect(runs, 2);
|
||||
});
|
||||
}
|
||||
90
halkyon_learn_flutter/test/core/async/watch_reads_test.dart
Normal file
90
halkyon_learn_flutter/test/core/async/watch_reads_test.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/async/watch_reads.dart';
|
||||
|
||||
void main() {
|
||||
late StreamController<void> changes;
|
||||
|
||||
setUp(() => changes = StreamController<void>.broadcast());
|
||||
tearDown(() => changes.close());
|
||||
|
||||
test('reads now and after changes; a burst during a read makes one more '
|
||||
'read, not one each', () async {
|
||||
var reads = 0;
|
||||
var gate = Completer<void>();
|
||||
final seen = <int>[];
|
||||
final subscription = watchReads(
|
||||
changes.stream,
|
||||
() async {
|
||||
final read = ++reads;
|
||||
await gate.future;
|
||||
return read;
|
||||
},
|
||||
what: 'numbers',
|
||||
logName: 'Test',
|
||||
).listen(seen.add);
|
||||
addTearDown(subscription.cancel);
|
||||
await pumpEventQueue();
|
||||
|
||||
changes
|
||||
..add(null)
|
||||
..add(null)
|
||||
..add(null);
|
||||
await pumpEventQueue();
|
||||
final first = gate;
|
||||
gate = Completer<void>()..complete();
|
||||
first.complete();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(seen, [1, 2]);
|
||||
expect(reads, 2);
|
||||
});
|
||||
|
||||
test('a failed read arrives as an error; the next change reads '
|
||||
'again', () async {
|
||||
var fail = true;
|
||||
final seen = <Object>[];
|
||||
final subscription = watchReads(
|
||||
changes.stream,
|
||||
() async {
|
||||
if (fail) {
|
||||
throw StateError('disk unavailable');
|
||||
}
|
||||
return 'read';
|
||||
},
|
||||
what: 'words',
|
||||
logName: 'Test',
|
||||
).listen(seen.add, onError: seen.add);
|
||||
addTearDown(subscription.cancel);
|
||||
await pumpEventQueue();
|
||||
|
||||
fail = false;
|
||||
changes.add(null);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(seen, [isA<StateError>(), 'read']);
|
||||
});
|
||||
|
||||
test('nothing more arrives once cancelled, though a read was under '
|
||||
'way', () async {
|
||||
final gate = Completer<void>();
|
||||
final seen = <String>[];
|
||||
final subscription = watchReads(
|
||||
changes.stream,
|
||||
() async {
|
||||
await gate.future;
|
||||
return 'late';
|
||||
},
|
||||
what: 'words',
|
||||
logName: 'Test',
|
||||
).listen(seen.add);
|
||||
await pumpEventQueue();
|
||||
|
||||
await subscription.cancel();
|
||||
gate.complete();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(seen, isEmpty);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/conflict_versions.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
enum _Field() {
|
||||
title,
|
||||
body,
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('shows every version of every field with its name, and Keep '
|
||||
'keeps that one', (tester) async {
|
||||
final kept = <(_Field, Object?)>[];
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: ConflictVersions<_Field>(
|
||||
versions: const {
|
||||
_Field.title: ['Kanji', 'Kanji N5'],
|
||||
_Field.body: ['Characters'],
|
||||
},
|
||||
nameOf: (field) => field.name,
|
||||
show: (version) => Text('$version'),
|
||||
onKept: (field, version) => kept.add((field, version)),
|
||||
keepLabel: 'Keep this',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('title'), findsNWidgets(2));
|
||||
expect(find.text('body'), findsOneWidget);
|
||||
expect(find.text('Keep this'), findsNWidgets(3));
|
||||
|
||||
await tester.tap(find.text('Keep this').at(1));
|
||||
expect(kept, [(_Field.title, 'Kanji N5')]);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
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';
|
||||
|
|
@ -69,7 +70,7 @@ void main() {
|
|||
await until((s) => s.loaded);
|
||||
|
||||
bloc!.add(const StepEditorEventChanged(StepField.title, 'は or が?'));
|
||||
await until((s) => s.status == StepSaveStatus.saved);
|
||||
await until((s) => s.status == EditorSaveStatus.saved);
|
||||
bloc!.add(
|
||||
const StepEditorEventChanged(
|
||||
StepField.exercise,
|
||||
|
|
@ -78,7 +79,7 @@ void main() {
|
|||
);
|
||||
await until(
|
||||
(s) =>
|
||||
s.status == StepSaveStatus.saved &&
|
||||
s.status == EditorSaveStatus.saved &&
|
||||
s.saved.exercise ==
|
||||
const SingleChoice(options: ['は', 'が', 'を'], correct: 0),
|
||||
);
|
||||
|
|
@ -92,7 +93,7 @@ void main() {
|
|||
open(idle: const Duration(hours: 1));
|
||||
await until((s) => s.loaded);
|
||||
bloc!.add(const StepEditorEventChanged(StepField.title, 'は or が?'));
|
||||
await until((s) => s.status == StepSaveStatus.editing);
|
||||
await until((s) => s.status == EditorSaveStatus.editing);
|
||||
|
||||
await bloc!.close();
|
||||
|
||||
|
|
@ -104,7 +105,7 @@ void main() {
|
|||
open(idle: const Duration(hours: 1));
|
||||
await until((s) => s.loaded);
|
||||
bloc!.add(const StepEditorEventChanged(StepField.title, 'は or が?'));
|
||||
await until((s) => s.status == StepSaveStatus.editing);
|
||||
await until((s) => s.status == EditorSaveStatus.editing);
|
||||
|
||||
const fixed = SingleChoice(options: ['は', 'が', 'を'], correct: 0);
|
||||
gateway.receiveRemoteRevision(
|
||||
|
|
@ -127,7 +128,7 @@ void main() {
|
|||
const mine = SingleChoice(options: ['は', 'が'], correct: 1);
|
||||
const theirs = SingleChoice(options: ['は', 'が', 'を'], correct: 0);
|
||||
bloc!.add(const StepEditorEventChanged(StepField.exercise, mine));
|
||||
await until((s) => s.status == StepSaveStatus.editing);
|
||||
await until((s) => s.status == EditorSaveStatus.editing);
|
||||
gateway.receiveRemoteRevision(
|
||||
id: 'srev-9000',
|
||||
stepId: created.stepId,
|
||||
|
|
@ -145,7 +146,9 @@ void main() {
|
|||
expect(bloc!.state.content.title, 'Which marks the topic?');
|
||||
|
||||
bloc!.add(const StepEditorEventVersionChosen(StepField.exercise, mine));
|
||||
await until((s) => s.conflicts.isEmpty && s.status == StepSaveStatus.saved);
|
||||
await until(
|
||||
(s) => s.conflicts.isEmpty && s.status == EditorSaveStatus.saved,
|
||||
);
|
||||
expect(gateway.written.length, writes + 1);
|
||||
expect((await stored()).exercise, mine);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/save_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_editor_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
|
|
@ -70,7 +71,7 @@ void main() {
|
|||
|
||||
expect(state.content, note('Particles', 'は'));
|
||||
expect(state.tagIds, {grammar});
|
||||
expect(state.status, NoteSaveStatus.clean);
|
||||
expect(state.status, EditorSaveStatus.clean);
|
||||
});
|
||||
|
||||
group('A new note', () {
|
||||
|
|
@ -79,7 +80,7 @@ void main() {
|
|||
await until((s) => s.loaded);
|
||||
|
||||
bloc!.add(const NoteEditorEventTitleChanged('Particles'));
|
||||
final saved = await until((s) => s.status == NoteSaveStatus.saved);
|
||||
final saved = await until((s) => s.status == EditorSaveStatus.saved);
|
||||
|
||||
expect(saved.noteId, isNotNull);
|
||||
expect(
|
||||
|
|
@ -108,11 +109,11 @@ void main() {
|
|||
await until((s) => s.loaded);
|
||||
|
||||
bloc!.add(NoteEditorEventBodyChanged(text('は and')));
|
||||
await until((s) => s.status == NoteSaveStatus.saved);
|
||||
await until((s) => s.status == EditorSaveStatus.saved);
|
||||
bloc!.add(NoteEditorEventBodyChanged(text('は and が')));
|
||||
await until(
|
||||
(s) =>
|
||||
s.status == NoteSaveStatus.saved && s.saved.body == text('は and が'),
|
||||
s.status == EditorSaveStatus.saved && s.saved.body == text('は and が'),
|
||||
);
|
||||
|
||||
final written = gateway.written;
|
||||
|
|
@ -132,7 +133,7 @@ void main() {
|
|||
bloc!
|
||||
..add(const NoteEditorEventTitleChanged('助詞'))
|
||||
..add(const NoteEditorEventSaveRequested());
|
||||
await until((s) => s.status == NoteSaveStatus.saved);
|
||||
await until((s) => s.status == EditorSaveStatus.saved);
|
||||
|
||||
expect((await notes.notesOf('japanese')).single.content.title, '助詞');
|
||||
});
|
||||
|
|
@ -144,7 +145,7 @@ void main() {
|
|||
open(noteId: id, idle: const Duration(hours: 1));
|
||||
await until((s) => s.loaded);
|
||||
bloc!.add(const NoteEditorEventTitleChanged('助詞'));
|
||||
await until((s) => s.status == NoteSaveStatus.editing);
|
||||
await until((s) => s.status == EditorSaveStatus.editing);
|
||||
|
||||
await bloc!.close();
|
||||
|
||||
|
|
@ -159,7 +160,7 @@ void main() {
|
|||
await until((s) => s.loaded);
|
||||
|
||||
bloc!.add(const NoteEditorEventTitleChanged('Particles and '));
|
||||
final saved = await until((s) => s.status == NoteSaveStatus.saved);
|
||||
final saved = await until((s) => s.status == EditorSaveStatus.saved);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
expect(bloc!.state.content.title, 'Particles and ');
|
||||
|
|
@ -178,7 +179,7 @@ void main() {
|
|||
bloc!.add(const NoteEditorEventTitleChanged('Particles '));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(bloc!.state.status, NoteSaveStatus.clean);
|
||||
expect(bloc!.state.status, EditorSaveStatus.clean);
|
||||
expect(gateway.written, hasLength(1));
|
||||
});
|
||||
|
||||
|
|
@ -198,7 +199,7 @@ void main() {
|
|||
final changed = await until((s) => s.content.body == text('は and が'));
|
||||
|
||||
expect(changed.revision, greaterThan(opened.revision));
|
||||
expect(changed.status, NoteSaveStatus.clean);
|
||||
expect(changed.status, EditorSaveStatus.clean);
|
||||
});
|
||||
|
||||
test("leaves the person's unsaved text alone, and the save merges "
|
||||
|
|
@ -207,7 +208,7 @@ void main() {
|
|||
open(noteId: created.noteId, idle: const Duration(hours: 1));
|
||||
await until((s) => s.loaded);
|
||||
bloc!.add(const NoteEditorEventTitleChanged('助詞'));
|
||||
await until((s) => s.status == NoteSaveStatus.editing);
|
||||
await until((s) => s.status == EditorSaveStatus.editing);
|
||||
|
||||
gateway.receiveRemoteRevision(
|
||||
id: 'nrev-9000',
|
||||
|
|
@ -232,7 +233,7 @@ void main() {
|
|||
open(noteId: created.noteId, idle: const Duration(hours: 1));
|
||||
await until((s) => s.loaded);
|
||||
bloc!.add(NoteEditorEventBodyChanged(text('は: topic')));
|
||||
await until((s) => s.status == NoteSaveStatus.editing);
|
||||
await until((s) => s.status == EditorSaveStatus.editing);
|
||||
gateway.receiveRemoteRevision(
|
||||
id: 'nrev-9000',
|
||||
noteId: created.noteId,
|
||||
|
|
@ -256,7 +257,7 @@ void main() {
|
|||
|
||||
bloc!.add(NoteEditorEventVersionChosen(NoteField.body, text('は: topic')));
|
||||
final settled = await until(
|
||||
(s) => s.conflicts.isEmpty && s.status == NoteSaveStatus.saved,
|
||||
(s) => s.conflicts.isEmpty && s.status == EditorSaveStatus.saved,
|
||||
);
|
||||
|
||||
expect(settled.content.body, text('は: topic'));
|
||||
|
|
@ -303,11 +304,11 @@ void main() {
|
|||
gateway.failWrites = Exception('disk');
|
||||
|
||||
bloc!.add(const NoteEditorEventTitleChanged('助詞'));
|
||||
await until((s) => s.status == NoteSaveStatus.failed);
|
||||
await until((s) => s.status == EditorSaveStatus.failed);
|
||||
|
||||
gateway.failWrites = null;
|
||||
bloc!.add(const NoteEditorEventTitleChanged('助詞!'));
|
||||
await until((s) => s.status == NoteSaveStatus.saved);
|
||||
await until((s) => s.status == EditorSaveStatus.saved);
|
||||
expect((await notes.notesOf('japanese')).single.content.title, '助詞!');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_progress.dart';
|
||||
|
||||
void main() {
|
||||
ExamProgress progress({
|
||||
int newCards = 0,
|
||||
int startedToday = 0,
|
||||
int due = 0,
|
||||
}) => (
|
||||
cards: 100,
|
||||
newCards: newCards,
|
||||
startedToday: startedToday,
|
||||
due: due,
|
||||
steps: 0,
|
||||
stepsDone: 0,
|
||||
pages: 0,
|
||||
pagesRead: 0,
|
||||
);
|
||||
|
||||
const ExamPace pace = (
|
||||
daysLeft: 30,
|
||||
newPerDay: 5,
|
||||
stepsPerWeek: 0,
|
||||
pagesPerDay: 0,
|
||||
);
|
||||
|
||||
test('studying for the exam takes every review due and the new cards '
|
||||
'the pace leaves for the day', () {
|
||||
expect(examCardsToStudy(progress(newCards: 40, due: 12), pace), 17);
|
||||
expect(
|
||||
examCardsToStudy(progress(newCards: 40, startedToday: 3, due: 2), pace),
|
||||
4,
|
||||
);
|
||||
expect(
|
||||
examCardsToStudy(progress(newCards: 40, startedToday: 7), pace),
|
||||
0,
|
||||
reason: 'more started today than the pace asks',
|
||||
);
|
||||
expect(
|
||||
examCardsToStudy(progress(newCards: 2, due: 1), pace),
|
||||
3,
|
||||
reason: 'no more new cards than there are',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_chapter.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A [PdfEngine] without PDFium, which `flutter test` can't load. A PDF's
|
||||
|
|
|
|||
Loading…
Reference in a new issue