Settings > Connectivity > Configure now points a terminal at a back office. Until this existed a store was wired up by editing syncConfigProvider and rebuilding, which made every terminal in a fleet its own build. - Transport picker (offline demo / HTTP / MQTT) with only the relevant fields shown, validated: an MQTT route with no host is refused rather than silently saved, because a terminal pointed at nothing looks exactly like one that is merely offline. - Terminal name and store id are editable and persist to the database. The device id and terminal code are shown but not editable, with a copy button — they are what a support call needs, and re-coding a till must not orphan the bills already written under the old code. - TLS defaults on, with a note that bills carry customer names and numbers. - About card now shows the real terminal, device id and store instead of the literal TERM-01, and the dead "Check for updates" button is now the entry point to this dialog. Lints cleared, analyzer now reports zero issues: - SoundService wrapped a plain bool in a getter and setter that did nothing. - Two post-await guards used context.mounted inside a State, which the analyzer cannot relate to the State's own lifetime. Both are now `mounted`. Tests: 140 -> 141. The new widget test drives the dialog end to end and asserts that saving an MQTT route with no host keeps the dialog open with the error visible. Suite run three times clean. Known gap, documented in docs/sync-contract.md: broker credentials live in memory and must be re-entered after a restart. Persisting them means encrypting at rest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
172 lines
6.2 KiB
Dart
172 lines
6.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:nearle_pos/app/app.dart';
|
|
import 'package:nearle_pos/data/datasources/local_store.dart';
|
|
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
|
import 'package:nearle_pos/domain/entities/shift_report.dart';
|
|
import 'package:nearle_pos/presentation/auth/providers/auth_controller.dart';
|
|
import 'package:nearle_pos/presentation/pos/providers/cart_controller.dart';
|
|
import 'package:nearle_pos/presentation/pos/screens/pos_dashboard_screen.dart';
|
|
import 'package:nearle_pos/presentation/sync/providers/sync_controller.dart';
|
|
|
|
/// Boots the real application widget.
|
|
///
|
|
/// The unit suite proves the money is right; this proves the thing actually
|
|
/// assembles — router guard, provider graph, theme and shell — which type
|
|
/// checking alone cannot tell you.
|
|
void main() {
|
|
setUpAll(() {
|
|
// Tests have no network, and an attempted font fetch throws.
|
|
GoogleFonts.config.allowRuntimeFetching = false;
|
|
LocalStore.registerSeed(
|
|
products: SeedData.products,
|
|
customers: SeedData.customers,
|
|
);
|
|
});
|
|
|
|
setUp(() async {
|
|
await LocalStore.instance.reset(withCatalogue: true);
|
|
});
|
|
|
|
ShiftReport blankReport() => ShiftReport.blank(
|
|
businessDate: DateTime(2026, 7, 31),
|
|
terminalId: 'TERM-01',
|
|
cashierName: 'Suriya',
|
|
);
|
|
|
|
Future<void> bootApp(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
// The background drain would open a broker connection and hit the
|
|
// disk on a clock this test controls. Neither is what these tests
|
|
// measure, and a half-driven timer would leak into the next one.
|
|
syncBootstrapProvider.overrideWith((ref) async {}),
|
|
|
|
// Catalogue reads come from the in-memory cache and resolve on the
|
|
// spot, but these four go to SQLite. Real disk I/O cannot be driven
|
|
// by the fake clock a widget test runs on: sqflite's own lock-warning
|
|
// timer is left pending and trips the binding's leak check. Stubbed
|
|
// so this test measures rendering, which is what it is for.
|
|
unsyncedCountProvider.overrideWith((ref) async => 0),
|
|
parkedBillsProvider.overrideWith((ref) async => []),
|
|
orderSyncRowsProvider.overrideWith((ref) async => []),
|
|
todayReportProvider.overrideWith((ref) async => blankReport()),
|
|
myShiftReportProvider.overrideWith((ref) async => blankReport()),
|
|
],
|
|
child: const NearlePosApp(),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
/// Pumps a fixed number of frames instead of settling.
|
|
///
|
|
/// Once past login the header subscribes to a periodic clock, so there is
|
|
/// always another frame pending and `pumpAndSettle` never returns.
|
|
Future<void> settle(WidgetTester tester, {int frames = 15}) async {
|
|
for (var i = 0; i < frames; i++) {
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
}
|
|
}
|
|
|
|
Future<void> signIn(WidgetTester tester) async {
|
|
final fields = find.byType(TextFormField);
|
|
await tester.enterText(fields.first, DemoCredentials.email);
|
|
await tester.enterText(fields.at(1), DemoCredentials.password);
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.text('Sign in').last);
|
|
await settle(tester);
|
|
}
|
|
|
|
testWidgets('the terminal starts on the login screen', (tester) async {
|
|
await bootApp(tester);
|
|
|
|
expect(find.byType(TextFormField), findsWidgets);
|
|
expect(find.text('Sign in'), findsWidgets);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('the auth guard keeps an unauthenticated terminal out',
|
|
(tester) async {
|
|
await bootApp(tester);
|
|
|
|
expect(find.byType(PosDashboardScreen), findsNothing);
|
|
});
|
|
|
|
testWidgets('signing in reaches the billing terminal', (tester) async {
|
|
await bootApp(tester);
|
|
await signIn(tester);
|
|
|
|
expect(find.byType(PosDashboardScreen), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('the shell renders every module without throwing',
|
|
(tester) async {
|
|
// Wide enough that the sidebar shows labels and the bill stays docked —
|
|
// the layout where a button label and its total compete for width.
|
|
tester.view.physicalSize = const Size(1800, 1200);
|
|
tester.view.devicePixelRatio = 1;
|
|
addTearDown(tester.view.reset);
|
|
|
|
await bootApp(tester);
|
|
await signIn(tester);
|
|
|
|
for (final label in [
|
|
'Customers',
|
|
'Product Import',
|
|
'Promo',
|
|
'Events',
|
|
'Settings',
|
|
'POS',
|
|
]) {
|
|
final target = find.text(label);
|
|
expect(target, findsWidgets, reason: 'no sidebar entry for "$label"');
|
|
|
|
await tester.tap(target.first);
|
|
await settle(tester);
|
|
expect(tester.takeException(), isNull, reason: 'opening "$label" threw');
|
|
}
|
|
});
|
|
|
|
testWidgets('the back office connection dialog opens and validates',
|
|
(tester) async {
|
|
// The only way a shop can point a till at a broker. Until it existed a
|
|
// store was wired up by editing a provider and rebuilding.
|
|
tester.view.physicalSize = const Size(1800, 1200);
|
|
tester.view.devicePixelRatio = 1;
|
|
addTearDown(tester.view.reset);
|
|
|
|
await bootApp(tester);
|
|
await signIn(tester);
|
|
|
|
await tester.tap(find.text('Settings').first);
|
|
await settle(tester);
|
|
|
|
await tester.tap(find.text('Configure').first);
|
|
await settle(tester);
|
|
|
|
// "Back office connection" is both the dialog title and the About card's
|
|
// button, so match the dialog itself.
|
|
expect(find.byType(AlertDialog), findsOneWidget);
|
|
expect(find.text('MQTT'), findsOneWidget);
|
|
|
|
// Switching to MQTT and saving with no host must be refused, not silently
|
|
// accepted — a terminal pointed at nothing looks identical to one that is
|
|
// simply offline.
|
|
await tester.tap(find.text('MQTT'));
|
|
await settle(tester);
|
|
await tester.tap(find.text('Save'));
|
|
await settle(tester);
|
|
|
|
expect(find.text('A broker host is required'), findsOneWidget);
|
|
expect(find.byType(AlertDialog), findsOneWidget,
|
|
reason: 'the dialog must stay open on a validation failure',);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|