Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
247 lines
8.7 KiB
Dart
247 lines
8.7 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
||
import 'package:miler/data/accepted_store.dart';
|
||
import 'package:miler/data/service_profile.dart';
|
||
import 'package:miler/data/work_scope.dart';
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// TWO LINES, ONE HANDSET, NO MIXING
|
||
///
|
||
/// Miler runs a milk-man round and a logistics day off one app and one login,
|
||
/// and it kept finished work under *global* SharedPreferences keys —
|
||
/// `completed_bookings` and friends. One phone, one drawer, whoever wrote
|
||
/// last: log out and back in as another rider, or move tenant, and the
|
||
/// previous scope's history was sitting in the new session's Activity.
|
||
///
|
||
/// These pin the ownership rule at the data boundary. Nothing here filters on
|
||
/// a display string — no kitchen names, no "Milk", no screen titles — only on
|
||
/// identity the session can prove: rider, tenant, line.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
void main() {
|
||
const milkA = WorkScope(userId: 38, tenantId: 13, line: ServiceLine.milkMan);
|
||
const logisticsA = WorkScope(
|
||
userId: 38,
|
||
tenantId: 13,
|
||
line: ServiceLine.parcel,
|
||
);
|
||
const milkB = WorkScope(userId: 99, tenantId: 13, line: ServiceLine.milkMan);
|
||
const otherTenant = WorkScope(
|
||
userId: 38,
|
||
tenantId: 77,
|
||
line: ServiceLine.milkMan,
|
||
);
|
||
|
||
group('the key', () {
|
||
test('rider, tenant and line each change the drawer', () {
|
||
final keys = {
|
||
milkA.scoped('completed_bookings'),
|
||
logisticsA.scoped('completed_bookings'),
|
||
milkB.scoped('completed_bookings'),
|
||
otherTenant.scoped('completed_bookings'),
|
||
};
|
||
expect(
|
||
keys.length,
|
||
4,
|
||
reason:
|
||
'all three identity parts must be in the key — two scopes '
|
||
'sharing a key is the leak itself',
|
||
);
|
||
expect(
|
||
milkA.scoped('completed_bookings'),
|
||
contains('completed_bookings'),
|
||
);
|
||
});
|
||
|
||
test('the same session resolves to the same drawer', () {
|
||
expect(
|
||
milkA.scoped('skipped_bookings'),
|
||
const WorkScope(
|
||
userId: 38,
|
||
tenantId: 13,
|
||
line: ServiceLine.milkMan,
|
||
).scoped('skipped_bookings'),
|
||
);
|
||
});
|
||
});
|
||
|
||
group('ownership', () {
|
||
test('a milk-man record is not a logistics record', () {
|
||
final row = milkA.stamp({'orderid': 'A1'});
|
||
expect(milkA.owns(row), isTrue);
|
||
expect(
|
||
logisticsA.owns(row),
|
||
isFalse,
|
||
reason: 'same rider, same tenant, other LINE — must not cross',
|
||
);
|
||
expect(logisticsA.excludes(row), isTrue);
|
||
});
|
||
|
||
test('another rider cannot claim it', () {
|
||
final row = milkA.stamp({'orderid': 'A1'});
|
||
expect(milkB.owns(row), isFalse);
|
||
expect(milkB.excludes(row), isTrue);
|
||
});
|
||
|
||
test('another tenant cannot claim it', () {
|
||
final row = milkA.stamp({'orderid': 'A1'});
|
||
expect(otherTenant.owns(row), isFalse);
|
||
expect(otherTenant.excludes(row), isTrue);
|
||
});
|
||
|
||
test('the API stamps its own identity and it is honoured', () {
|
||
// Rows straight off `/miler/assignments` carry the rider; no scope stamp
|
||
// is involved and it must still be respected.
|
||
expect(milkA.excludes({'orderid': 'A1', 'mileruserid': 99}), isTrue);
|
||
expect(milkA.excludes({'orderid': 'A1', 'mileruserid': 38}), isFalse);
|
||
});
|
||
|
||
test('silence is read differently by the two questions', () {
|
||
// A row with no identity: `owns` refuses to adopt it (used on legacy
|
||
// rows, where inventing ownership IS the leak), `excludes` keeps it
|
||
// (used on rows the API just returned for this session).
|
||
final bare = {'orderid': 'A1'};
|
||
expect(milkA.owns(bare), isFalse);
|
||
expect(milkA.excludes(bare), isFalse);
|
||
});
|
||
});
|
||
|
||
group('the store, scoped', () {
|
||
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
|
||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||
|
||
String today() {
|
||
final n = DateTime.now();
|
||
return '${n.year}-${n.month.toString().padLeft(2, '0')}-'
|
||
'${n.day.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
test(
|
||
'completed work written as one rider is invisible to the next',
|
||
() async {
|
||
// Rider 38 finishes a stop.
|
||
SharedPreferences.setMockInitialValues({'userid': 38});
|
||
await addCompletedBookings([
|
||
{'orderid': 'MILK-1', 'pickupcustomer': 'Joe'},
|
||
], terminalStatus: 'delivered');
|
||
final mine = await getCompletedBookings();
|
||
expect(mine.map((r) => r['orderid']), contains('MILK-1'));
|
||
|
||
// Rider 99 signs in on the same handset. Same day, same store, same
|
||
// process — a different drawer.
|
||
SharedPreferences.setMockInitialValues({'userid': 99});
|
||
final theirs = await getCompletedBookings();
|
||
expect(
|
||
theirs.where((r) => r['orderid'] == 'MILK-1'),
|
||
isEmpty,
|
||
reason: "logging in must never expose the previous rider's history",
|
||
);
|
||
},
|
||
);
|
||
|
||
test('switching line cannot expose the other line’s history', () async {
|
||
SharedPreferences.setMockInitialValues({'userid': 38});
|
||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||
await addCompletedBookings([
|
||
{'orderid': 'ROUND-1'},
|
||
], terminalStatus: 'delivered');
|
||
expect(
|
||
(await getCompletedBookings()).map((r) => r['orderid']),
|
||
contains('ROUND-1'),
|
||
);
|
||
|
||
// Same rider, same tenant, logistics day.
|
||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||
expect(
|
||
(await getCompletedBookings()).where((r) => r['orderid'] == 'ROUND-1'),
|
||
isEmpty,
|
||
reason: 'a milk-man delivery belongs only to milk-man Activity',
|
||
);
|
||
|
||
// And a logistics collection stays out of the round's history.
|
||
await addCompletedBookings([
|
||
{'orderid': 'PARCEL-1'},
|
||
], terminalStatus: 'picked');
|
||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||
expect(
|
||
(await getCompletedBookings()).where((r) => r['orderid'] == 'PARCEL-1'),
|
||
isEmpty,
|
||
reason: 'a logistics pickup belongs only to logistics Activity',
|
||
);
|
||
});
|
||
|
||
test('a stored record carries the identity that produced it', () async {
|
||
SharedPreferences.setMockInitialValues({'userid': 38});
|
||
await addCompletedBookings([
|
||
{'orderid': 'STAMP-1'},
|
||
], terminalStatus: 'delivered');
|
||
final row = (await getCompletedBookings()).single;
|
||
expect(row['scopeuserid'], 38);
|
||
expect(row['scopeline'], ServiceLine.milkMan.name);
|
||
});
|
||
|
||
test('logout empties this scope', () async {
|
||
SharedPreferences.setMockInitialValues({'userid': 38});
|
||
await addCompletedBookings([
|
||
{'orderid': 'BYE-1'},
|
||
], terminalStatus: 'delivered');
|
||
expect(await getCompletedBookings(), isNotEmpty);
|
||
|
||
await clearScopedStores();
|
||
expect(
|
||
await getCompletedBookings(),
|
||
isEmpty,
|
||
reason: 'signing out must not leave records for the next rider',
|
||
);
|
||
});
|
||
|
||
test('unattributable legacy rows are dropped, not adopted', () async {
|
||
// The pre-scoping global key, holding somebody's rows. Nothing on them
|
||
// says whose, so adopting them would be inventing ownership.
|
||
SharedPreferences.setMockInitialValues({
|
||
'userid': 38,
|
||
'completed_bookings': jsonEncode([
|
||
{'orderid': 'LEGACY-1', 'completedday': today()},
|
||
]),
|
||
});
|
||
|
||
await migrateLegacyStores();
|
||
|
||
expect(
|
||
(await getCompletedBookings()).where((r) => r['orderid'] == 'LEGACY-1'),
|
||
isEmpty,
|
||
reason: 'a row that cannot prove ownership must not be adopted',
|
||
);
|
||
final prefs = await SharedPreferences.getInstance();
|
||
expect(
|
||
prefs.containsKey('completed_bookings'),
|
||
isFalse,
|
||
reason: 'the global key is drained so it can never be read again',
|
||
);
|
||
});
|
||
|
||
test('a legacy row that proves ownership is carried across', () async {
|
||
SharedPreferences.setMockInitialValues({
|
||
'userid': 38,
|
||
'completed_bookings': jsonEncode([
|
||
{
|
||
'orderid': 'LEGACY-MINE',
|
||
'completedday': today(),
|
||
'mileruserid': 38,
|
||
'scopeline': 'milkMan',
|
||
},
|
||
]),
|
||
});
|
||
|
||
await migrateLegacyStores();
|
||
|
||
expect(
|
||
(await getCompletedBookings()).map((r) => r['orderid']),
|
||
contains('LEGACY-MINE'),
|
||
);
|
||
});
|
||
});
|
||
}
|