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:
parent
a35318a5a6
commit
18c1370bf6
2 changed files with 728 additions and 1 deletions
|
|
@ -0,0 +1,64 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_text_import.dart';
|
||||
|
||||
void main() {
|
||||
test('one card per line, split at its first separator, so a back may '
|
||||
'hold one too', () {
|
||||
expect(parseCardLines('hola\thello\nadiós\tgood\tbye', CardSeparator.tab), [
|
||||
(front: 'hola', back: 'hello'),
|
||||
(front: 'adiós', back: 'good\tbye'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('leaves out blank lines, lines without a separator and lines with '
|
||||
'an empty side, such as a header without a back', () {
|
||||
expect(
|
||||
parseCardLines(
|
||||
'Spanish vocab\n\nhola;hello\n;nothing in front\nnothing behind;\n',
|
||||
CardSeparator.semicolon,
|
||||
),
|
||||
[(front: 'hola', back: 'hello')],
|
||||
);
|
||||
});
|
||||
|
||||
test('trims both sides, and reads Windows line endings', () {
|
||||
expect(
|
||||
parseCardLines(' hola , hello \r\nsí,yes\r\n', CardSeparator.comma),
|
||||
[
|
||||
(front: 'hola', back: 'hello'),
|
||||
(front: 'sí', back: 'yes'),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('reads quoted fields as spreadsheets write them: separators inside, '
|
||||
'a doubled quote for one', () {
|
||||
expect(
|
||||
parseCardLines(
|
||||
'"hello, ""friend""",hola amigo\nthank you,"gracias, señor"',
|
||||
CardSeparator.comma,
|
||||
),
|
||||
[
|
||||
(front: 'hello, "friend"', back: 'hola amigo'),
|
||||
(front: 'thank you', back: 'gracias, señor'),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves out a line whose quoted front never closes', () {
|
||||
expect(parseCardLines('"hello,hola', CardSeparator.comma), isEmpty);
|
||||
});
|
||||
|
||||
test('ignores the byte order mark spreadsheets put before UTF-8 text', () {
|
||||
expect(parseCardLines('hola,hello', CardSeparator.comma), [
|
||||
(front: 'hola', back: 'hello'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('a file says its separator by its name: commas for .csv, tabs for '
|
||||
'the rest', () {
|
||||
expect(CardSeparator.forFile('words.CSV'), CardSeparator.comma);
|
||||
expect(CardSeparator.forFile('words.tsv'), CardSeparator.tab);
|
||||
expect(CardSeparator.forFile('words.txt'), CardSeparator.tab);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue