feat(app): import flashcards from text

Import content gains Flashcards: pasted, or from a CSV or TSV file, one
card per line, split at its first tab, comma or semicolon (a .csv picks
commas), lines without both sides left out. Quoted fields are read as
spreadsheets write them, and a UTF-8 byte order mark is ignored. The
cards come in at the studyset's end with the origin "text", so the
dashboard doesn't count them as written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-08 14:43:49 +02:00
commit 18c1370bf6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,141 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_file_picker.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeStudysetGateway studysets;
late FakeCardGateway cardGateway;
late FakeFilePicker files;
setUp(() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'spanish',
encodeStudysetPayload(StudysetContent.titled('Spanish vocab')),
);
cardGateway = FakeCardGateway();
files = FakeFilePicker();
});
Future<void> showApp(WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: cardGateway,
files: files,
),
location: RoutePaths.studyset('spanish'),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
/// Opens the studyset's Import content and chooses Flashcards.
Future<void> openImport(WidgetTester tester) async {
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('Flashcards').last);
await tester.pumpAndSettle();
}
Future<List<(String, String)>> stored() async {
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
return [
for (final card in await cards.cardsOf('spanish'))
(
card.content.front.plainText.trim(),
card.content.back.plainText.trim(),
),
];
}
testWidgets('pasted lines become front/back cards at the end of the '
'studyset, in order, marked as imported; the Flashcards tab shows '
'them', (tester) async {
await showApp(tester);
await openImport(tester);
expect(find.text('No cards found yet.'), findsOneWidget);
expect(
tester
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Import'))
.onPressed,
isNull,
);
await tester.enterText(
find.byType(TextField),
'hola\thello\nadiós\tgoodbye\nno separator here',
);
await tester.pump();
expect(find.text('2 cards ready to import.'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
await tester.pumpAndSettle();
expect(find.text('Imported 2 flashcards.'), findsOneWidget);
expect(await stored(), [('hola', 'hello'), ('adiós', 'goodbye')]);
expect(
[
for (final revision in await cardGateway.allRevisions())
cardOriginOf(revision.payload),
],
['text', 'text'],
);
expect(find.text('hola'), findsOneWidget);
});
testWidgets('a CSV file fills the field and switches to commas', (
tester,
) async {
await showApp(tester);
await openImport(tester);
files.next = (
name: 'words.csv',
bytes: Uint8List.fromList(utf8.encode('"hello, friend",hola amigo\n')),
);
await tester.tap(find.text('Load a CSV or TSV file'));
await tester.pumpAndSettle();
expect(files.asked.single, ['csv', 'tsv', 'txt']);
expect(find.text('1 card ready to import.'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
await tester.pumpAndSettle();
expect(await stored(), [('hello, friend', 'hola amigo')]);
});
testWidgets("a file that isn't UTF-8 text says so", (tester) async {
await showApp(tester);
await openImport(tester);
files.next = (name: 'words.csv', bytes: Uint8List.fromList([0xff, 0xfe]));
await tester.tap(find.text('Load a CSV or TSV file'));
await tester.pumpAndSettle();
expect(find.text("This file isn't text in UTF-8."), findsOneWidget);
});
}