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:
parent
11cbdf3022
commit
92fd84590b
10 changed files with 835 additions and 2 deletions
153
apps/app_seeds/test/services/lot_history_test.dart
Normal file
153
apps/app_seeds/test/services/lot_history_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue