feat(learn): an archive's import shows its steps, counts and percent, with Abort
Importing a Halkyon archive showed one bar. It now says which of its three steps it is in (copying files; writing studysets, flashcards and notes; bringing over the progress), how many of the step's items are done and the percent of all of it, and offers Abort (decided 9 Nov 2026). The copier checks between items, never leaving one half written, and keeps a trail of what it wrote: the top studyset once made, and the files' rows it made (not those the person had, found before each file is added). Aborted, the importer deletes the top studyset for good with everything inside it, as Delete now does (the reviews, answers and pages read going with their cards, steps and PDFs), and those rows; the tags it made stay, as names the person may well use anyway. Once all of it is written, Abort no longer undoes it. WorkProgress, WorkStages and AbortSignal (core/work/) and WorkProgressView (core/widget/) are shared with the Anki import and the exports, which follow. Whats-New: Importing an archive shows its steps and how far it has come, and Abort stops it, keeping nothing of it. Whats-New-De: Der Import eines Archivs zeigt seine Schritte und wie weit er ist, und „Abbrechen“ beendet ihn, ohne etwas davon zu behalten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
8502fb38f0
commit
5f7cb70c3c
3 changed files with 901 additions and 53 deletions
|
|
@ -28,6 +28,7 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.da
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
|
@ -93,6 +94,8 @@ final class _Person() {
|
|||
progress,
|
||||
studysets,
|
||||
pdfs,
|
||||
StudysetDeletions(studysets, cards, notes, paths, pdfs),
|
||||
assets,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import 'dart:convert';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||
|
|
@ -23,6 +25,7 @@ import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart
|
|||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
|
@ -257,12 +260,16 @@ void main() {
|
|||
late TagRepository tags;
|
||||
late ReviewRepository reviews;
|
||||
late LearningPathRepository paths;
|
||||
late NoteRepository notes;
|
||||
late PdfSourceRepository pdfs;
|
||||
late AssetRepository assets;
|
||||
late FakeAssetGateway assetRows;
|
||||
late ArchiveImporter importer;
|
||||
|
||||
setUp(() {
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
final notes = NoteRepository(FakeNoteGateway());
|
||||
notes = NoteRepository(FakeNoteGateway());
|
||||
paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
tags = TagRepository(
|
||||
FakeTagGateway(),
|
||||
|
|
@ -270,13 +277,14 @@ void main() {
|
|||
noteChanges: notes.changes,
|
||||
pathChanges: paths.changes,
|
||||
);
|
||||
final assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
assetRows = FakeAssetGateway();
|
||||
assets = AssetRepository(
|
||||
assetRows,
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
FakeAssetSealing(),
|
||||
);
|
||||
final pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
|
||||
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
|
||||
reviews = ReviewRepository(FakeReviewGateway());
|
||||
importer = ArchiveImporter(
|
||||
ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets),
|
||||
|
|
@ -299,9 +307,139 @@ void main() {
|
|||
PathProgressRepository(FakePathAnswerGateway()),
|
||||
studysets,
|
||||
pdfs,
|
||||
StudysetDeletions(studysets, cards, notes, paths, pdfs),
|
||||
assets,
|
||||
);
|
||||
});
|
||||
|
||||
group('aborted (decided 9 Nov 2026)', () {
|
||||
final kept = Uint8List.fromList(utf8.encode('a picture the person has'));
|
||||
final brought = Uint8List.fromList(utf8.encode('a picture it brings'));
|
||||
String hashOf(Uint8List bytes) => sha256.convert(bytes).toString();
|
||||
|
||||
/// [japanese] with both pictures among its files.
|
||||
Map<String, Object> withFiles() => {
|
||||
...japanese(),
|
||||
'files.json': {
|
||||
'kept': {
|
||||
'sha256': hashOf(kept),
|
||||
'mimeType': 'image/png',
|
||||
'payload': {'schemaVersion': 1, 'fileName': 'kept.png'},
|
||||
},
|
||||
'brought': {
|
||||
'sha256': hashOf(brought),
|
||||
'mimeType': 'image/png',
|
||||
'payload': {'schemaVersion': 1, 'fileName': 'brought.png'},
|
||||
},
|
||||
},
|
||||
'files/${hashOf(kept)}': kept,
|
||||
'files/${hashOf(brought)}': brought,
|
||||
};
|
||||
|
||||
test('goes through its steps in order, to the whole', () async {
|
||||
final seen = <WorkProgress<ArchiveImportStage>>[];
|
||||
await importer.import(
|
||||
readArchive(zip(withFiles())),
|
||||
onProgress: seen.add,
|
||||
);
|
||||
|
||||
expect(
|
||||
{for (final progress in seen) progress.stage}.toList(),
|
||||
ArchiveImportStage.values,
|
||||
);
|
||||
expect(seen.every((progress) => progress.steps == 3), isTrue);
|
||||
for (final (i, progress) in seen.indexed.skip(1)) {
|
||||
expect(
|
||||
progress.fraction,
|
||||
greaterThanOrEqualTo(seen[i - 1].fraction),
|
||||
reason: '$progress after ${seen[i - 1]}',
|
||||
);
|
||||
}
|
||||
final files = seen.lastWhere(
|
||||
(progress) => progress.stage == ArchiveImportStage.files,
|
||||
);
|
||||
expect((files.done, files.total), (2, 2));
|
||||
expect(seen.last.percent, 100);
|
||||
});
|
||||
|
||||
test(
|
||||
'deletes everything it wrote, not the file the person had',
|
||||
() async {
|
||||
final own = await studysets.create(StudysetContent.titled('Own'));
|
||||
final ownFile = await assets.add(
|
||||
kept,
|
||||
fileName: 'mine.png',
|
||||
mimeType: 'image/png',
|
||||
studysetId: own,
|
||||
);
|
||||
final abort = AbortSignal();
|
||||
|
||||
await expectLater(
|
||||
importer.import(
|
||||
readArchive(zip(withFiles())),
|
||||
abort: abort,
|
||||
onProgress: (progress) {
|
||||
if (progress.stage == ArchiveImportStage.content &&
|
||||
progress.done >= 2) {
|
||||
abort.abort();
|
||||
}
|
||||
},
|
||||
),
|
||||
throwsA(isA<WorkAborted>()),
|
||||
);
|
||||
|
||||
expect([for (final s in await studysets.withDeleted()) s.id], [own]);
|
||||
expect(await cards.allCards(), isEmpty);
|
||||
expect([for (final row in await assetRows.all()) row.id], [ownFile]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'in its last step, deletes the cards its reviews were written for',
|
||||
() async {
|
||||
final abort = AbortSignal();
|
||||
|
||||
await expectLater(
|
||||
importer.import(
|
||||
readArchive(zip(japanese())),
|
||||
abort: abort,
|
||||
onProgress: (progress) {
|
||||
if (progress.stage == ArchiveImportStage.progress &&
|
||||
progress.done == 1) {
|
||||
abort.abort();
|
||||
}
|
||||
},
|
||||
),
|
||||
throwsA(isA<WorkAborted>()),
|
||||
);
|
||||
|
||||
// The reviews written go with their cards: the database
|
||||
// cascades (`review_log.card`), which the fakes don't.
|
||||
expect(await studysets.withDeleted(), isEmpty);
|
||||
expect(await cards.allCards(), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('asked once all of it is written, it is done', () async {
|
||||
final abort = AbortSignal();
|
||||
|
||||
final imported = await importer.import(
|
||||
readArchive(zip(japanese())),
|
||||
abort: abort,
|
||||
onProgress: (progress) {
|
||||
if (progress.percent == 100) {
|
||||
abort.abort();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
[for (final s in await studysets.all()) s.id],
|
||||
contains(imported.studysetId),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('an archive this app cannot read all of writes nothing, not even '
|
||||
'the tags it lists', () async {
|
||||
final archive = readArchive(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/work_progress_view.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -15,10 +18,12 @@ import 'archive_test.dart' show japanese, zip;
|
|||
void main() {
|
||||
late FakeFilePicker files;
|
||||
late FakeStudysetGateway studysets;
|
||||
late FakeCardGateway cards;
|
||||
|
||||
setUp(() {
|
||||
files = FakeFilePicker();
|
||||
studysets = FakeStudysetGateway();
|
||||
cards = FakeCardGateway();
|
||||
});
|
||||
|
||||
Future<void> show(WidgetTester tester) async {
|
||||
|
|
@ -29,7 +34,7 @@ void main() {
|
|||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: studysets,
|
||||
cardGateway: FakeCardGateway(),
|
||||
cardGateway: cards,
|
||||
files: files,
|
||||
dictionaryGateway: FakeDictionaryGateway(
|
||||
words: [
|
||||
|
|
@ -77,6 +82,38 @@ void main() {
|
|||
expect((await repository.all()).length, 2);
|
||||
});
|
||||
|
||||
testWidgets('shows its steps under way, and Abort deletes what it wrote '
|
||||
'(decided 9 Nov 2026)', (tester) async {
|
||||
files.next = (name: 'japanese.zip', bytes: zip(japanese()));
|
||||
final hold = cards.holdWrites = Completer<void>();
|
||||
await show(tester);
|
||||
|
||||
await choose(tester);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.text('Step 2 of 3: Writing studysets, flashcards and notes'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('%'), findsOneWidget);
|
||||
await tester.tap(find.byKey(WorkProgressView.abortKey));
|
||||
await tester.pump();
|
||||
expect(find.text('Aborting…'), findsOneWidget);
|
||||
hold.complete();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Import aborted: nothing of it was kept.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(StudysetScreen), findsNothing);
|
||||
final repository = StudysetRepository(studysets);
|
||||
addTearDown(repository.dispose);
|
||||
expect(await repository.withDeleted(), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('a file that is no archive says so, and imports nothing', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
|
|||
Loading…
Reference in a new issue