feat(app): show each batch's story as a composite timeline

The Movement log (history + provenance DAG) was recorded but invisible.
A new history sheet on every lot tile narrates the batch: movements,
germination tests, storage checks and how it entered the collection
(origin attached), most-recent first. Two one-tap chips record 'sown
today' / 'harvested today' — the first UI door to the sown/harvested
movement types. No schema change; one-shot reads only (no live
subscriptions in a transient sheet).
This commit is contained in:
vjrj 2026-07-18 06:25:55 +02:00
parent 11cbdf3022
commit 92fd84590b
10 changed files with 835 additions and 2 deletions

View file

@ -0,0 +1,153 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import '../support/test_support.dart';
void main() {
late AppDatabase db;
late VarietyRepository repo;
setUp(() {
db = newTestDatabase();
repo = newTestRepository(db);
});
tearDown(() async => db.close());
Future<String> newLot({String? originName, String? originPlace}) async {
final varietyId = await repo.addQuickVariety(label: 'Tomate rosa');
return repo.addLot(
varietyId: varietyId,
harvestYear: 2025,
originName: originName,
originPlace: originPlace,
);
}
group('recordCultivation', () {
test('records a sown movement with defaults', () async {
final lotId = await newLot();
final id = await repo.recordCultivation(
lotId: lotId,
type: MovementType.sown,
);
expect(id, isNotEmpty);
final history = await repo.lotHistory(lotId);
final sown = history.where(
(e) => e.movementType == MovementType.sown,
);
expect(sown, hasLength(1));
expect(sown.first.kind, LotHistoryKind.movement);
expect(sown.first.when, isNotNull);
});
test('records a harvested movement with a note and date', () async {
final lotId = await newLot();
await repo.recordCultivation(
lotId: lotId,
type: MovementType.harvested,
occurredOn: DateTime(2026, 7, 1).millisecondsSinceEpoch,
notes: 'great year',
);
final history = await repo.lotHistory(lotId);
final harvested = history.singleWhere(
(e) => e.movementType == MovementType.harvested,
);
expect(harvested.notes, 'great year');
expect(
harvested.when,
DateTime(2026, 7, 1).millisecondsSinceEpoch,
);
});
test('rejects non-cultivation movement types', () async {
final lotId = await newLot();
expect(
() => repo.recordCultivation(lotId: lotId, type: MovementType.given),
throwsA(isA<ArgumentError>()),
);
});
});
group('lotHistory', () {
test('starts with the lot creation entry carrying its origin', () async {
final lotId = await newLot(originName: 'María', originPlace: 'Aiako');
final history = await repo.lotHistory(lotId);
expect(history, isNotEmpty);
// Most-recent first: creation is the last entry.
final created = history.last;
expect(created.kind, LotHistoryKind.created);
expect(created.originName, 'María');
expect(created.originPlace, 'Aiako');
});
test('merges movements, germination tests and condition checks', () async {
final lotId = await newLot();
await repo.addGerminationTest(
lotId: lotId,
testedOn: DateTime(2026, 2, 1).millisecondsSinceEpoch,
sampleSize: 10,
germinatedCount: 8,
);
await repo.addConditionCheck(
lotId: lotId,
checkedOn: DateTime(2026, 3, 1).millisecondsSinceEpoch,
containerCount: 2,
desiccantState: DesiccantState.dry,
);
await repo.recordCultivation(
lotId: lotId,
type: MovementType.sown,
occurredOn: DateTime(2026, 4, 1).millisecondsSinceEpoch,
);
await repo.recordHandover(
lotId: lotId,
direction: HandoverDirection.iGave,
counterparty: 'Pepe',
);
final history = await repo.lotHistory(lotId);
expect(history.map((e) => e.kind).toSet(), {
LotHistoryKind.created,
LotHistoryKind.movement,
LotHistoryKind.germinationTest,
LotHistoryKind.conditionCheck,
});
final germination = history.singleWhere(
(e) => e.kind == LotHistoryKind.germinationTest,
);
expect(germination.germinationRate, closeTo(0.8, 1e-9));
final check = history.singleWhere(
(e) => e.kind == LotHistoryKind.conditionCheck,
);
expect(check.containerCount, 2);
// Most-recent first: the handover (today) leads, creation closes.
expect(history.first.movementType, MovementType.given);
expect(history.last.kind, LotHistoryKind.created);
// The middle entries are ordered by their explicit dates, descending.
final dated = history
.where((e) => e.kind != LotHistoryKind.created)
.toList();
for (var i = 0; i + 1 < dated.length; i++) {
expect(dated[i].when ?? 0, greaterThanOrEqualTo(dated[i + 1].when ?? 0));
}
});
test('ignores other lots', () async {
final lotId = await newLot();
final otherLot = await newLot();
await repo.recordCultivation(lotId: otherLot, type: MovementType.sown);
final history = await repo.lotHistory(lotId);
expect(
history.where((e) => e.kind == LotHistoryKind.movement),
isEmpty,
);
});
});
}

View file

@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import '../support/test_support.dart';
void main() {
late AppDatabase db;
setUp(() => db = newTestDatabase());
tearDown(() => db.close());
testWidgets('the lot tile opens the batch story, closed by its origin', (
tester,
) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
final lotId = await repo.addLot(
varietyId: id,
originName: 'María',
originPlace: 'Aiako',
);
await repo.addGerminationTest(
lotId: lotId,
sampleSize: 10,
germinatedCount: 9,
);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('lot.history.$lotId')));
await tester.pumpAndSettle();
// The story: germination test (90%) first, creation-with-origin last.
expect(find.text('Germination test — 90%'), findsOneWidget);
expect(find.text('Added to your collection'), findsOneWidget);
expect(find.text('From María · Aiako'), findsOneWidget);
await disposeTree(tester);
});
testWidgets('one tap records sown today and it shows up in the story', (
tester,
) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
final lotId = await repo.addLot(varietyId: id);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('lot.history.$lotId')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('history.sowToday')));
await tester.pumpAndSettle();
// One-shot .get() (NOT watch().first hangs under the fake-async clock).
final movements = await db.select(db.movements).get();
expect(movements.single.type, MovementType.sown);
expect(movements.single.lotId, lotId);
expect(movements.single.occurredOn, isNotNull);
// And the story refreshed to show it.
expect(find.text('Sown'), findsOneWidget);
await disposeTree(tester);
});
testWidgets('a plant lot offers harvest but not sowing', (tester) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Rosemary');
final lotId = await repo.addLot(varietyId: id, type: LotType.plant);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('lot.history.$lotId')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('history.sowToday')), findsNothing);
expect(find.byKey(const Key('history.harvestToday')), findsOneWidget);
await disposeTree(tester);
});
}