feat(block1): inventory walking skeleton + first quick-add slice

Stand up the Tanemaki monorepo and the first end-to-end vertical slice of
Block 1 (offline, encrypted inventory): add a seed → see it in a categorized,
searchable list → it persists → reopen and it's still there.

Architecture (state management like G1nkgo, adapted to Tane's reality):
- flutter_bloc (Cubit-first), but the encrypted Drift DB is the single source
  of truth; cubits stream from repositories (no hydrated_bloc/Hive, which would
  write plaintext at rest).
- get_it composition root; go_router; slang i18n (ES/EN, Weblate-friendly JSON).

Workspace & core:
- pub workspace: packages/commons_core (pure Dart) + apps/app_seeds (Flutter).
- commons_core primitives: UUIDv7 IdGen, Hybrid Logical Clock, Quantity value
  type (+ plant-aware QuantityKind), IdentityService root-seed stub.

Data & security:
- Drift schemaVersion=1 with all 10 Block-1 tables + common CRDT columns
  (HLC updated_at, last_author, tombstones); Movement append-only.
- SQLCipher via an injectable executor that refuses to open a plaintext DB;
  DB key + root seed in the OS keystore (separate secrets).
- Exported drift_schema_v1.json + migration scaffold.

Tests (near-TDD; nothing merges without tests):
- commons_core units (24), Drift migration test, "no plaintext at rest"
  security guard (runs where SQLCipher is present, skips otherwise),
  repository, widget, full quick-add flow, file-reopen persistence, and a
  no-hardcoded-strings i18n guard. GitLab CI: format + analyze + test + coverage.

Follow-on Block-1 stories (item detail/edit, species catalog, germination UI)
and all of Block 2 remain out of scope.
This commit is contained in:
vjrj 2026-07-07 15:16:14 +02:00
parent 57c0eeadaf
commit 040f15a898
131 changed files with 19855 additions and 20 deletions

View file

@ -0,0 +1,91 @@
import 'package:commons_core/commons_core.dart';
import 'package:test/test.dart';
void main() {
group('Hlc', () {
test('round-trips through pack/parse', () {
const h = Hlc(millis: 1720000000000, counter: 7, nodeId: 'node-a');
expect(Hlc.parse(h.pack()), h);
});
test('parse tolerates a nodeId containing colons', () {
const h = Hlc(millis: 42, counter: 1, nodeId: 'a:b:c');
expect(Hlc.parse(h.pack()), h);
});
test('packed form sorts lexically in timestamp order', () {
const older = Hlc(millis: 100, counter: 9, nodeId: 'z');
const newer = Hlc(millis: 200, counter: 0, nodeId: 'a');
expect(older.pack().compareTo(newer.pack()), lessThan(0));
});
test('packed counter breaks ties within the same millisecond', () {
const a = Hlc(millis: 100, counter: 1, nodeId: 'n');
const b = Hlc(millis: 100, counter: 2, nodeId: 'n');
expect(a.pack().compareTo(b.pack()), lessThan(0));
});
group('localEvent', () {
test(
'advances millis and resets counter when wall clock moves forward',
() {
const clock = Hlc(millis: 100, counter: 4, nodeId: 'n');
final next = clock.localEvent(200);
expect(next.millis, 200);
expect(next.counter, 0);
},
);
test('bumps counter when wall clock is unchanged', () {
const clock = Hlc(millis: 100, counter: 4, nodeId: 'n');
final next = clock.localEvent(100);
expect(next.millis, 100);
expect(next.counter, 5);
});
test('stays monotonic when the wall clock runs backwards', () {
const clock = Hlc(millis: 100, counter: 4, nodeId: 'n');
final next = clock.localEvent(50); // clock skew
expect(next.millis, 100);
expect(next.counter, 5);
expect(next.compareTo(clock), greaterThan(0));
});
});
group('receiveEvent', () {
test('takes the max millis and bumps beyond both counters on a tie', () {
const local = Hlc(millis: 100, counter: 2, nodeId: 'local');
const remote = Hlc(millis: 100, counter: 5, nodeId: 'remote');
final merged = local.receiveEvent(remote, 100);
expect(merged.millis, 100);
expect(merged.counter, 6);
expect(merged.nodeId, 'local'); // identity stays local
});
test('adopts a remote future timestamp and continues its counter', () {
const local = Hlc(millis: 100, counter: 2, nodeId: 'local');
const remote = Hlc(millis: 300, counter: 1, nodeId: 'remote');
final merged = local.receiveEvent(remote, 120);
expect(merged.millis, 300);
expect(merged.counter, 2);
});
test('resets counter when the wall clock leads everything', () {
const local = Hlc(millis: 100, counter: 2, nodeId: 'local');
const remote = Hlc(millis: 150, counter: 9, nodeId: 'remote');
final merged = local.receiveEvent(remote, 400);
expect(merged.millis, 400);
expect(merged.counter, 0);
});
});
test('compareTo orders by millis, then counter, then nodeId', () {
const a = Hlc(millis: 1, counter: 0, nodeId: 'a');
const b = Hlc(millis: 1, counter: 0, nodeId: 'b');
const c = Hlc(millis: 1, counter: 1, nodeId: 'a');
const d = Hlc(millis: 2, counter: 0, nodeId: 'a');
final sorted = [d, c, b, a]..sort();
expect(sorted, [a, b, c, d]);
});
});
}

View file

@ -0,0 +1,37 @@
import 'dart:math';
import 'package:commons_core/commons_core.dart';
import 'package:test/test.dart';
void main() {
group('IdentityService', () {
test('generates a 32-byte root seed', () {
final seed = IdentityService().generateRootSeed();
expect(seed.length, IdentityService.rootSeedLengthBytes);
expect(seed.length, 32);
});
test('two seeds from a secure RNG differ', () {
final service = IdentityService();
expect(service.generateRootSeed(), isNot(service.generateRootSeed()));
});
test('is reproducible with an injected seeded RNG (test-only)', () {
// `equals` does element-wise comparison on the byte lists.
final s1 = IdentityService(random: Random(1)).generateRootSeed();
final s2 = IdentityService(random: Random(1)).generateRootSeed();
expect(s1, equals(s2));
});
});
group('randomBytes', () {
test('returns the requested length', () {
expect(randomBytes(16).length, 16);
expect(randomBytes(0).length, 0);
});
test('rejects negative lengths', () {
expect(() => randomBytes(-1), throwsArgumentError);
});
});
}

View file

@ -0,0 +1,35 @@
import 'package:commons_core/commons_core.dart';
import 'package:test/test.dart';
void main() {
group('IdGen', () {
final idGen = IdGen();
test('produces well-formed UUIDv7 strings', () {
final id = idGen.newId();
// 8-4-4-4-12 hex groups.
expect(
id,
matches(
RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
),
),
);
// Version nibble is 7.
expect(id.split('-')[2][0], '7');
});
test('never collides across many calls', () {
final ids = List.generate(5000, (_) => idGen.newId());
expect(ids.toSet().length, ids.length);
});
test('is time-sortable: later ids sort after earlier ones', () async {
final earlier = idGen.newId();
await Future<void>.delayed(const Duration(milliseconds: 3));
final later = idGen.newId();
expect(earlier.compareTo(later), lessThan(0));
});
});
}

View file

@ -0,0 +1,47 @@
import 'package:commons_core/commons_core.dart';
import 'package:test/test.dart';
void main() {
group('Quantity', () {
test('value equality ignores object identity', () {
const a = Quantity(kind: QuantityKind.pod, precise: 12, label: 'a bag');
const b = Quantity(kind: QuantityKind.pod, precise: 12, label: 'a bag');
expect(a, b);
expect(a.hashCode, b.hashCode);
});
test('isPrecise reflects the presence of a precise amount', () {
expect(
const Quantity(kind: QuantityKind.grams, precise: 5).isPrecise,
isTrue,
);
expect(const Quantity(kind: QuantityKind.aFew).isPrecise, isFalse);
});
test('copyWith overrides only the given fields', () {
const q = Quantity(kind: QuantityKind.cob, precise: 2);
final q2 = q.copyWith(kind: QuantityKind.ear);
expect(q2.kind, QuantityKind.ear);
expect(q2.precise, 2);
});
});
group('QuantityKind', () {
test('enum names are stable storage keys', () {
// Guards against accidental renames these strings live in the DB.
expect(QuantityKind.pod.name, 'pod');
expect(QuantityKind.cob.name, 'cob');
expect(QuantityKind.head.name, 'head');
expect(QuantityKind.packet.name, 'packet');
expect(QuantityKind.handful.name, 'handful');
expect(QuantityKind.grams.name, 'grams');
expect(QuantityKind.count.name, 'count');
});
test('every kind belongs to a group', () {
for (final kind in QuantityKind.values) {
expect(QuantityGroup.values, contains(kind.group));
}
});
});
}