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 _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> _summary(List> 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>> 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, ]; } /// 2 Duvel and 1 Cola in order o1, then 1 Duvel in o2. Future 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.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 = >{}; 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> openTabs; late DateTime now; late BarScreenViewModel viewModel; final orderIds = []; setUp(() async { barTabService = MockBarTabService(); openTabs = StreamController>.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)); }); }); }