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:
Tizian 「ティツィアーン」 2026-10-17 13:51:51 +02:00
commit bb7757d7f3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 757 additions and 650 deletions

View file

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

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

View file

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

View file

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

View file

@ -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, '助詞!');
});
}

View file

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

View file

@ -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