Files
kooltab/test/tab_orders_test.dart

510 lines
15 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter/material.dart' hide Table;
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:kooltap/database/app_database.dart';
import 'package:kooltap/l10n/app_localizations.dart';
import 'package:kooltap/models/bar_tab.dart';
import 'package:kooltap/models/product.dart';
import 'package:kooltap/models/server_tab.dart';
import 'package:kooltap/models/tab_item.dart';
import 'package:kooltap/models/tab_item_purchase.dart';
import 'package:kooltap/services/bar_tab_service.dart';
import 'package:kooltap/utils/money.dart';
import 'package:kooltap/utils/tab_orders.dart';
import 'package:kooltap/viewmodels/bar_screen_view_model.dart';
import 'package:kooltap/views/bar/selected_tab_details.dart';
import 'package:mocktail/mocktail.dart';
const _duvel = Product(
id: 'duvel',
name: 'Duvel',
category: 'Drinks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 400,
);
const _cola = Product(
id: 'cola',
name: 'Cola',
category: 'Drinks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 250,
);
class MockBarTabService extends Mock implements BarTabService {}
OrderDrink<String> _drink(
String name,
DateTime at, {
String? orderId,
bool removed = false,
}) {
return OrderDrink(
line: name,
lineKey: name,
id: '$name-${at.toIso8601String()}-$removed',
addedAt: at,
orderId: orderId,
isRemoved: removed,
);
}
List<List<Object?>> _summary(List<TabOrder<String>> orders) => [
for (final order in orders)
[
order.orderId,
order.startedAt,
[for (final row in order.rows) '${row.quantity}x ${row.line}'],
],
];
void main() {
final t1232 = DateTime(2026, 10, 28, 12, 32);
final t1310 = DateTime(2026, 10, 28, 13, 10);
group('groupOrders', () {
test('shows each order under the time it started, oldest first', () {
final orders = groupOrders([
_drink('Duvel', t1310, orderId: 'o2'),
_drink('Duvel', t1232, orderId: 'o1'),
_drink('Duvel', t1232, orderId: 'o1'),
_drink('Cola', t1232.add(const Duration(minutes: 1)), orderId: 'o1'),
_drink('Beer', t1310.add(const Duration(minutes: 1)), orderId: 'o2'),
]);
expect(_summary(orders), [
[
'o1',
t1232,
['2x Duvel', '1x Cola'],
],
[
'o2',
t1310,
['1x Duvel', '1x Beer'],
],
]);
});
test('groups drinks without an order by time: over 5 minutes apart is '
'a new order', () {
final orders = groupOrders([
_drink('Duvel', t1232),
_drink('Cola', t1232.add(const Duration(minutes: 4))),
_drink('Beer', t1310),
]);
expect(_summary(orders), [
[
null,
t1232,
['1x Duvel', '1x Cola'],
],
[
null,
t1310,
['1x Beer'],
],
]);
});
test('counts drinks taken off again apart', () {
final orders = groupOrders([
_drink('Duvel', t1232, orderId: 'o1'),
_drink('Duvel', t1232, orderId: 'o1', removed: true),
]);
expect(orders.single.rows.single.quantity, 1);
expect(orders.single.rows.single.removed, 1);
});
});
test('orderTimeLabel says today, yesterday or the date', () async {
await initializeDateFormatting('en');
final l10n = lookupAppLocalizations(const Locale('en'));
final now = DateTime(2026, 10, 28, 14);
expect(orderTimeLabel(l10n, 'en', t1232, now: now), '12:32 today');
expect(
orderTimeLabel(
l10n,
'en',
t1232.subtract(const Duration(days: 1)),
now: now,
),
'12:32 yesterday',
);
expect(
orderTimeLabel(l10n, 'en', DateTime(2026, 10, 20, 9, 5), now: now),
'Oct 20, 2026 09:05',
);
});
group('DriftBarTabService orders', () {
late AppDatabase database;
late DriftBarTabService service;
setUp(() async {
database = AppDatabase(NativeDatabase.memory());
service = DriftBarTabService(database: database);
for (final product in [_duvel, _cola]) {
await database
.into(database.products)
.insert(
ProductsCompanion.insert(
id: product.id,
name: product.name,
category: product.category,
stockQuantity: product.stockQuantity,
lowStockThreshold: product.lowStockThreshold,
priceInCents: product.priceInCents,
),
);
}
});
tearDown(() => database.close());
Future<List<Map<String, dynamic>>> outbox() async {
final rows =
await (database.select(database.tabEvents)..orderBy([
(row) => OrderingTerm.asc(const CustomExpression('rowid')),
]))
.get();
return [
for (final row in rows) jsonDecode(row.payload) as Map<String, dynamic>,
];
}
/// 2 Duvel and 1 Cola in order o1, then 1 Duvel in o2.
Future<BarTab> ringUp() async {
final tab = await service.createTab(customerName: 'Bram');
for (final (product, orderId) in [
(_duvel, 'o1'),
(_duvel, 'o1'),
(_cola, 'o1'),
(_duvel, 'o2'),
]) {
// Taps are never at the same moment
await Future<void>.delayed(const Duration(milliseconds: 5));
await service.addProductToTab(
tabId: tab.id,
product: product,
orderId: orderId,
);
}
return (await service.getTabById(tab.id))!;
}
test(
'keeps the order of every drink and sends it with the event',
() async {
final tab = await ringUp();
expect(
[
for (final order in ordersOfTab(tab))
[
order.orderId,
[
for (final row in order.rows)
'${row.quantity}x ${row.line.productName}',
],
],
],
[
[
'o1',
['2x Duvel', '1x Cola'],
],
[
'o2',
['1x Duvel'],
],
],
);
expect((await outbox()).skip(1).map((e) => e['orderId']), [
'o1',
'o1',
'o1',
'o2',
]);
},
);
test('takes a drink off the order it is shown in', () async {
final tab = await ringUp();
final duvel = tab.items.firstWhere((item) => item.productId == 'duvel');
final applied = await service.adjustTabItemQuantity(
tabItemId: duvel.id,
delta: -5,
order: (orderId: 'o2'),
);
expect(applied, -1);
final orders = ordersOfTab((await service.getTabById(tab.id))!);
expect(orders.map((order) => order.orderId), ['o1']);
expect((await outbox()).last, containsPair('orderId', 'o2'));
});
test('deleting a tab takes every line off whatever its orders', () async {
final tab = await ringUp();
await service.deleteTab(tab.id);
final removals = (await outbox()).where(
(e) => e['type'] == 'line_removed',
);
// Without an order: the server takes everything on the line off
expect([
for (final e in removals)
'${e['productName']} ${e.containsKey('orderId')} ${e['quantity']}',
], unorderedEquals(['Duvel false 3', 'Cola false 1']));
});
test('a paid tab keeps its orders in history', () async {
final tab = await ringUp();
await service.closeTab(tab.id);
final closed = (await service.getClosedTabs()).single;
expect(ordersOfClosedTab(closed).map((order) => order.orderId), [
'o1',
'o2',
]);
});
test(
'replays a removal on the order it was made on, after a sync',
() async {
final openedAt = DateTime(2026, 10, 28, 12);
ServerUnit unit(String id, String orderId, int minutes) => ServerUnit(
id: id,
addedAt: openedAt.add(Duration(minutes: minutes)),
orderId: orderId,
);
final serverTab = ServerTab(
id: 'tab-1',
customerName: 'Bram',
openedAt: openedAt,
version: 3,
lines: [
ServerTabLine(
id: 'line-1',
productId: 'duvel',
productName: 'Duvel',
unitPriceInCents: 400,
quantity: 2,
units: [unit('u1', 'o1', 0), unit('u2', 'o2', 40)],
),
],
);
await service.applyServerState([serverTab]);
final tab = (await service.getTabById('tab-1'))!;
await service.adjustTabItemQuantity(
tabItemId: tab.items.single.id,
delta: -1,
order: (orderId: 'o1'),
);
// The server has not seen the removal yet: it is replayed on top
await service.applyServerState([serverTab]);
final orders = ordersOfTab((await service.getTabById('tab-1'))!);
expect(orders.map((order) => order.orderId), ['o2']);
},
);
test('a drink keeps its order when a server without orders sends it '
'back', () async {
final tab = await ringUp();
final events = await outbox();
// The server applied every event and knows nothing of orders
await database.delete(database.tabEvents).go();
final serverTab = ServerTab(
id: tab.id,
customerName: tab.customerName,
openedAt: tab.openedAt,
version: tab.version,
lines: [
for (final item in tab.items)
ServerTabLine(
id: item.id,
productId: item.productId,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
quantity: item.quantity,
units: [
for (final event in events)
if (event['lineId'] == item.id)
ServerUnit(
id: '${event['id']}:0',
addedAt: DateTime.parse(
event['occurredAt'] as String,
).toLocal(),
),
],
),
],
);
await service.applyServerState([serverTab]);
final orders = ordersOfTab((await service.getTabById(tab.id))!);
expect(orders.map((order) => order.orderId), ['o1', 'o2']);
});
});
group('SelectedTabDetails', () {
BarTab tabWith(List<(String product, String orderId, DateTime at)> drinks) {
final purchases = <String, List<TabItemPurchase>>{};
for (final (index, (product, orderId, at)) in drinks.indexed) {
purchases
.putIfAbsent(product, () => [])
.add(
TabItemPurchase(
id: 'p$index',
tabItemId: product,
purchasedAt: at,
orderId: orderId,
),
);
}
return BarTab(
id: 'tab-1',
customerName: 'Bram',
openedAt: t1232,
items: [
for (final MapEntry(key: product, value: units) in purchases.entries)
TabItem(
id: product,
tabId: 'tab-1',
productId: product,
productName: product,
quantity: units.length,
unitPriceInCents: 400,
purchases: units,
),
],
);
}
testWidgets('folds earlier orders under their time and total', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SelectedTabDetails(
tab: tabWith([
(
'Duvel',
'o1',
DateTime.now().subtract(const Duration(hours: 1)),
),
(
'Duvel',
'o1',
DateTime.now().subtract(const Duration(hours: 1)),
),
('Beer', 'o2', DateTime.now()),
]),
onItemQuantityChanged: (_, _, _) async {},
onCloseTabPressed: () {},
),
),
),
);
// The earlier order shows its total only; the current one its drinks
expect(find.textContaining('– ${800.asEuro}'), findsOneWidget);
expect(find.text('Duvel'), findsNothing);
expect(find.text('Beer'), findsOneWidget);
await tester.tap(find.textContaining('– ${800.asEuro}'));
await tester.pump();
expect(find.text('Duvel'), findsOneWidget);
});
});
group('BarScreenViewModel orders', () {
late MockBarTabService barTabService;
late StreamController<List<BarTab>> openTabs;
late DateTime now;
late BarScreenViewModel viewModel;
final orderIds = <String?>[];
setUp(() async {
barTabService = MockBarTabService();
openTabs = StreamController<List<BarTab>>.broadcast();
when(
() => barTabService.watchOpenTabs(),
).thenAnswer((_) => openTabs.stream);
orderIds.clear();
when(
() => barTabService.addProductToTab(
tabId: any(named: 'tabId'),
product: any(named: 'product'),
orderId: any(named: 'orderId'),
),
).thenAnswer((invocation) async {
orderIds.add(invocation.namedArguments[#orderId] as String?);
});
now = DateTime(2026, 10, 28, 12, 32);
viewModel = BarScreenViewModel(
barTabService: barTabService,
now: () => now,
);
viewModel.ensureLoaded();
openTabs.add([
BarTab(id: 'a', customerName: 'Bram', openedAt: now, items: const []),
BarTab(id: 'b', customerName: 'Jan', openedAt: now, items: const []),
]);
await pumpEventQueue();
viewModel.openTab('a');
});
setUpAll(() => registerFallbackValue(_duvel));
tearDown(() async {
viewModel.dispose();
await openTabs.close();
});
test('drinks within 5 minutes of each other are one order', () async {
await viewModel.addProductToSelectedTab(_duvel);
now = now.add(const Duration(minutes: 4));
await viewModel.addProductToSelectedTab(_cola);
now = now.add(const Duration(minutes: 5));
await viewModel.addProductToSelectedTab(_duvel);
expect(orderIds[0], orderIds[1]);
expect(orderIds[2], isNot(orderIds[1]));
});
test('closing the tab and coming back starts a new order', () async {
await viewModel.addProductToSelectedTab(_duvel);
viewModel.deselectTab();
viewModel.selectTab('a');
await viewModel.addProductToSelectedTab(_duvel);
viewModel.selectTab('b');
viewModel.selectTab('a');
await viewModel.addProductToSelectedTab(_duvel);
expect(orderIds.toSet(), hasLength(3));
});
});
}