Merge branch 'worktree-print-labels': print seed labels with QR
# Conflicts: # apps/app_seeds/lib/i18n/strings.g.dart # apps/app_seeds/lib/ui/inventory_list_screen.dart
This commit is contained in:
commit
3deb93a5c6
25 changed files with 1522 additions and 115 deletions
|
|
@ -0,0 +1,90 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/data/export_import/seed_label_codec.dart';
|
||||
|
||||
void main() {
|
||||
group('SeedLabelCodec.encode', () {
|
||||
test('encodes all fields as a tanemaki://seed URI', () {
|
||||
final uri = SeedLabelCodec.encode(
|
||||
const SeedLabelData(
|
||||
varietyLabel: 'Acelga de Perales',
|
||||
scientificName: 'Beta vulgaris',
|
||||
commonName: 'Acelga',
|
||||
year: 2006,
|
||||
origin: 'Perales',
|
||||
),
|
||||
);
|
||||
expect(uri, startsWith('tanemaki://seed?'));
|
||||
final parsed = Uri.parse(uri);
|
||||
expect(parsed.scheme, 'tanemaki');
|
||||
expect(parsed.host, 'seed');
|
||||
expect(parsed.queryParameters, {
|
||||
'v': 'Acelga de Perales',
|
||||
's': 'Beta vulgaris',
|
||||
'c': 'Acelga',
|
||||
'y': '2006',
|
||||
'o': 'Perales',
|
||||
});
|
||||
});
|
||||
|
||||
test('omits absent fields, keeping the payload small', () {
|
||||
final uri = SeedLabelCodec.encode(
|
||||
const SeedLabelData(varietyLabel: 'Alubia de Tolosa'),
|
||||
);
|
||||
final parsed = Uri.parse(uri);
|
||||
expect(parsed.queryParameters, {'v': 'Alubia de Tolosa'});
|
||||
expect(uri, isNot(contains('&s=')));
|
||||
expect(uri, isNot(contains('y=')));
|
||||
});
|
||||
|
||||
test('treats blank optional fields as absent', () {
|
||||
final uri = SeedLabelCodec.encode(
|
||||
const SeedLabelData(
|
||||
varietyLabel: 'Maíz',
|
||||
scientificName: ' ',
|
||||
origin: '',
|
||||
),
|
||||
);
|
||||
expect(Uri.parse(uri).queryParameters, {'v': 'Maíz'});
|
||||
});
|
||||
});
|
||||
|
||||
group('SeedLabelCodec round-trip', () {
|
||||
test('decode reverses encode for full data', () {
|
||||
const data = SeedLabelData(
|
||||
varietyLabel: 'Tomate Rosa',
|
||||
scientificName: 'Solanum lycopersicum',
|
||||
commonName: 'Tomate',
|
||||
year: 2024,
|
||||
origin: 'Huerta de la abuela, León',
|
||||
);
|
||||
expect(SeedLabelCodec.decode(SeedLabelCodec.encode(data)), data);
|
||||
});
|
||||
|
||||
test('survives unicode, spaces and separators', () {
|
||||
const data = SeedLabelData(
|
||||
varietyLabel: 'Grelo & nabo "do país"',
|
||||
origin: 'A Coruña / Galiza',
|
||||
commonName: 'بامية', // RTL script
|
||||
);
|
||||
final decoded = SeedLabelCodec.decode(SeedLabelCodec.encode(data));
|
||||
expect(decoded, data);
|
||||
});
|
||||
});
|
||||
|
||||
group('SeedLabelCodec.decode', () {
|
||||
test('rejects a foreign scheme', () {
|
||||
expect(SeedLabelCodec.decode('https://seed?v=x'), isNull);
|
||||
expect(SeedLabelCodec.decode('tanemaki://other?v=x'), isNull);
|
||||
});
|
||||
|
||||
test('rejects a payload without a variety', () {
|
||||
expect(SeedLabelCodec.decode('tanemaki://seed?s=Beta%20vulgaris'), isNull);
|
||||
});
|
||||
|
||||
test('ignores an unparseable year rather than throwing', () {
|
||||
final decoded = SeedLabelCodec.decode('tanemaki://seed?v=Maíz&y=old');
|
||||
expect(decoded?.varietyLabel, 'Maíz');
|
||||
expect(decoded?.year, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/data/species_repository.dart';
|
||||
import 'package:tane/data/variety_repository.dart';
|
||||
import 'package:tane/db/database.dart';
|
||||
import 'package:tane/db/enums.dart';
|
||||
|
|
@ -85,4 +86,51 @@ void main() {
|
|||
expect(stamps.toSet().length, 2);
|
||||
expect(() => stamps.map(Hlc.parse).toList(), returnsNormally);
|
||||
});
|
||||
|
||||
group('labelRows', () {
|
||||
test('expands one entry per lot and resolves the locale common name', () async {
|
||||
final speciesRepo = newTestSpeciesRepository(db);
|
||||
await speciesRepo.seedBundled(const [
|
||||
SpeciesSeed(
|
||||
scientificName: 'Beta vulgaris',
|
||||
family: 'Amaranthaceae',
|
||||
commonNames: {
|
||||
'en': ['Chard'],
|
||||
'es': ['Acelga'],
|
||||
},
|
||||
),
|
||||
]);
|
||||
final speciesId = (await db.select(db.species).getSingle()).id;
|
||||
|
||||
final v = await repo.addQuickVariety(
|
||||
label: 'Acelga de Perales',
|
||||
category: 'Amaranthaceae',
|
||||
);
|
||||
await repo.linkSpecies(v, speciesId);
|
||||
await repo.addLot(varietyId: v, harvestYear: 2006, originName: 'Perales');
|
||||
await repo.addLot(varietyId: v, harvestYear: 2008);
|
||||
|
||||
final rows = await repo.labelRows({v}, languageCode: 'es');
|
||||
expect(rows.length, 2); // one per lot
|
||||
expect(rows.first.commonName, 'Acelga'); // locale-picked, not 'Chard'
|
||||
expect(rows.first.scientificName, 'Beta vulgaris');
|
||||
expect(rows.map((r) => r.harvestYear), [2006, 2008]);
|
||||
expect(rows.first.originName, 'Perales');
|
||||
});
|
||||
|
||||
test('yields a single name-only entry for a variety with no lots', () async {
|
||||
final v = await repo.addQuickVariety(label: 'Alubia de Tolosa');
|
||||
final rows = await repo.labelRows({v}, languageCode: 'en');
|
||||
expect(rows.single.varietyLabel, 'Alubia de Tolosa');
|
||||
expect(rows.single.harvestYear, isNull);
|
||||
expect(rows.single.commonName, isNull);
|
||||
});
|
||||
|
||||
test('includes only varieties in the selection', () async {
|
||||
final selected = await repo.addQuickVariety(label: 'Selected');
|
||||
await repo.addQuickVariety(label: 'Not selected');
|
||||
final rows = await repo.labelRows({selected}, languageCode: 'en');
|
||||
expect(rows.map((r) => r.varietyLabel), ['Selected']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
92
apps/app_seeds/test/services/label_sheet_service_test.dart
Normal file
92
apps/app_seeds/test/services/label_sheet_service_test.dart
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/services/file_service.dart';
|
||||
import 'package:tane/services/label_sheet_service.dart';
|
||||
|
||||
/// Captures the bytes handed to the save dialog instead of touching disk.
|
||||
class _RecordingFileService implements FileService {
|
||||
Uint8List? saved;
|
||||
String? savedName;
|
||||
|
||||
@override
|
||||
Future<String?> saveFile({
|
||||
required String suggestedName,
|
||||
required Uint8List bytes,
|
||||
}) async {
|
||||
saved = bytes;
|
||||
savedName = suggestedName;
|
||||
return '/dev/null/$suggestedName';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
|
||||
null;
|
||||
}
|
||||
|
||||
const _labels = [
|
||||
LabelSheetLabel(
|
||||
varietyLabel: 'Acelga de Perales',
|
||||
commonName: 'Acelga',
|
||||
scientificName: 'Beta vulgaris',
|
||||
details: '2006 · Perales',
|
||||
qrData: 'tanemaki://seed?v=Acelga+de+Perales&y=2006',
|
||||
),
|
||||
LabelSheetLabel(
|
||||
varietyLabel: 'Alubia de Tolosa',
|
||||
qrData: 'tanemaki://seed?v=Alubia+de+Tolosa',
|
||||
),
|
||||
];
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
for (final format in LabelSheetFormat.values) {
|
||||
test('buildPdf renders a well-formed PDF for $format', () async {
|
||||
final service = LabelSheetService(files: _RecordingFileService());
|
||||
final bytes = await service.buildPdf(labels: _labels, format: format);
|
||||
|
||||
// %PDF header + non-trivial body; text content is compressed, so the
|
||||
// human-readable assertions live in the widget/data tests.
|
||||
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
|
||||
expect(bytes.length, greaterThan(1000));
|
||||
});
|
||||
}
|
||||
|
||||
test('buildPdf renders a name-only label (no optional fields)', () async {
|
||||
final service = LabelSheetService(files: _RecordingFileService());
|
||||
final bytes = await service.buildPdf(
|
||||
labels: const [
|
||||
LabelSheetLabel(varietyLabel: 'Maíz', qrData: 'tanemaki://seed?v=Maíz'),
|
||||
],
|
||||
format: LabelSheetFormat.stickers,
|
||||
);
|
||||
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
|
||||
});
|
||||
|
||||
test('buildPdf honours an RTL text direction', () async {
|
||||
final service = LabelSheetService(files: _RecordingFileService());
|
||||
final bytes = await service.buildPdf(
|
||||
labels: const [
|
||||
LabelSheetLabel(varietyLabel: 'بامية', qrData: 'tanemaki://seed?v=x'),
|
||||
],
|
||||
format: LabelSheetFormat.cards,
|
||||
rtl: true,
|
||||
);
|
||||
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
|
||||
});
|
||||
|
||||
test('saveLabels hands the PDF to the save dialog', () async {
|
||||
final files = _RecordingFileService();
|
||||
final service = LabelSheetService(files: files);
|
||||
final saved = await service.saveLabels(
|
||||
labels: _labels,
|
||||
format: LabelSheetFormat.cards,
|
||||
suggestedName: 'tanemaki-labels-2026-07-10.pdf',
|
||||
);
|
||||
|
||||
expect(saved, isTrue);
|
||||
expect(files.savedName, 'tanemaki-labels-2026-07-10.pdf');
|
||||
expect(String.fromCharCodes(files.saved!.take(5)), '%PDF-');
|
||||
});
|
||||
}
|
||||
|
|
@ -147,4 +147,50 @@ void main() {
|
|||
expect(state.categories, hasLength(2));
|
||||
expect(state.categories.toSet(), {'Poaceae', 'Fabaceae'});
|
||||
});
|
||||
|
||||
group('selection mode', () {
|
||||
test('enterSelection starts the mode with one id', () async {
|
||||
await repo.addQuickVariety(label: 'Bean');
|
||||
final state = await waitFor(cubit, (s) => s.items.isNotEmpty);
|
||||
final id = state.items.single.id;
|
||||
|
||||
cubit.enterSelection(id);
|
||||
expect(cubit.state.selectionMode, isTrue);
|
||||
expect(cubit.state.selectedIds, {id});
|
||||
});
|
||||
|
||||
test('toggleSelection adds then removes, staying in mode', () async {
|
||||
await repo.addQuickVariety(label: 'Bean');
|
||||
final state = await waitFor(cubit, (s) => s.items.isNotEmpty);
|
||||
final id = state.items.single.id;
|
||||
|
||||
cubit.toggleSelection(id);
|
||||
expect(cubit.state.selectedIds, {id});
|
||||
|
||||
cubit.toggleSelection(id);
|
||||
expect(cubit.state.selectedIds, isEmpty);
|
||||
expect(cubit.state.selectionMode, isTrue);
|
||||
});
|
||||
|
||||
test('selectAllVisible selects only the filtered subset', () async {
|
||||
await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae');
|
||||
await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae');
|
||||
final state = await waitFor(cubit, (s) => s.items.length == 2);
|
||||
final beanId = state.items.firstWhere((i) => i.label == 'Bean').id;
|
||||
|
||||
cubit.toggleCategory('Fabaceae'); // hide the tomato
|
||||
cubit.selectAllVisible();
|
||||
expect(cubit.state.selectedIds, {beanId});
|
||||
});
|
||||
|
||||
test('exitSelection leaves the mode and clears the selection', () async {
|
||||
await repo.addQuickVariety(label: 'Bean');
|
||||
final state = await waitFor(cubit, (s) => s.items.isNotEmpty);
|
||||
cubit.enterSelection(state.items.single.id);
|
||||
|
||||
cubit.exitSelection();
|
||||
expect(cubit.state.selectionMode, isFalse);
|
||||
expect(cubit.state.selectedIds, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,4 +219,66 @@ void main() {
|
|||
await disposeTree(tester);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('the label toolbar enters selection and Select all enables print', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = newTestRepository(db);
|
||||
await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae');
|
||||
await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae');
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapScreen(repository: repo, child: const InventoryListScreen()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('inventory.selectLabels')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Nothing picked yet: the count reads zero and print is disabled.
|
||||
expect(find.text('0 selected'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<IconButton>(find.byKey(const Key('inventory.selection.print')))
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('inventory.selection.selectAll')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('2 selected'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<IconButton>(find.byKey(const Key('inventory.selection.print')))
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
|
||||
// Closing the contextual bar leaves selection mode.
|
||||
await tester.tap(find.byKey(const Key('inventory.selection.close')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('inventory.selection.print')), findsNothing);
|
||||
await disposeTree(tester);
|
||||
});
|
||||
|
||||
testWidgets('long-pressing a tile enters selection with it picked', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = newTestRepository(db);
|
||||
await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae');
|
||||
await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae');
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapScreen(repository: repo, child: const InventoryListScreen()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(find.text('Tomato'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('1 selected'), findsOneWidget);
|
||||
expect(find.byType(Checkbox), findsNWidgets(2)); // one per tile
|
||||
await disposeTree(tester);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue