diff --git a/docs/automation-contract.md b/docs/automation-contract.md index 91f1c314..92091f57 100644 --- a/docs/automation-contract.md +++ b/docs/automation-contract.md @@ -84,7 +84,7 @@ listed below disappears, is renamed, or stops being namespaced. | `order.create.submit`, `order.create.cancel`, `order.confirm.home` | create order | Submit / cancel; back to home from the confirmation screen. | | `order.take.confirm`, `order.take.close`, `order.take.amount`, `order.take.amount.confirm` | take order | Take the order; range amount dialog. | | `order.id` | trade | Read-only order id (label = id). | -| `order.status` | trade | Read-only order status; label is the wire status (`pending`, `waiting-payment`, `active`, `fiat-sent`, `success`, `canceled`, ...). | +| `order.status` | trade | Read-only order status; label is the wire status (`pending`, `waiting-payment`, `active`, `fiat-sent`, `success`, `canceled`, ...). On the maker's own pending order the trade detail shows the creator reputation instead of the Mostro message card, so there the status comes from an invisible readout; exactly one node either way. | | `trades.item.`, `trades.item.status` | trades | Trade row; status chip whose label is the wire status. | | `trade.` (`trade.payInvoice`, `trade.addInvoice`, `trade.fiatSent`, `trade.release`, `trade.takeSell`, `trade.takeBuy`, `trade.rate`, `trade.cancel`, `trade.dispute`, ...) | trade | Trade action buttons named after the protocol action. | | `trade.release.confirm`, `trade.cancel.confirm`, `trade.dispute.confirm` | trade | Confirmation dialogs. | diff --git a/lib/core/automation/automation_id.dart b/lib/core/automation/automation_id.dart index 4ea68f9d..73d25d4a 100644 --- a/lib/core/automation/automation_id.dart +++ b/lib/core/automation/automation_id.dart @@ -76,3 +76,28 @@ extension AutomationIdExtension on Widget { Widget withAutomationId(String id, {bool merge = true, String? label}) => AutomationId(id, merge: merge, label: label, child: this); } + +/// An invisible readout that puts a business state on the accessibility tree. +/// +/// Some screens carry state that no visible control names: the invoice being +/// paid, or the order status on a branch that replaces the card normally +/// showing it. This draws nothing (a one-pixel box) and exposes [value] as +/// the label of the node identified by [id], so a black-box driver — and a +/// screen reader — can read the state where there would otherwise be +/// silence. +/// +/// [value] is the wire value the harness asserts on, not a localized string; +/// see `docs/automation-contract.md`. +class AutomationReadout extends StatelessWidget { + const AutomationReadout(this.id, {super.key, required this.value}); + + /// One of the `AutomationIds` constants or helpers. + final String id; + + /// The wire value exposed as the accessibility label. + final String value; + + @override + Widget build(BuildContext context) => const SizedBox(width: 1, height: 1) + .withAutomationId(id, merge: false, label: value); +} diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 35e41587..3ca0f5c7 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -80,13 +80,10 @@ class _PayLightningInvoiceScreenState // NWC auto-payment flow header, const SizedBox(height: 24), - // Automation readout: the invoice being paid, so a black-box - // driver can correlate the payment by hash without reading the - // QR code. Invisible; screen readers get the invoice string. - const SizedBox(width: 1, height: 1).withAutomationId( - AutomationIds.payInvoiceText, - merge: false, - label: lnInvoice), + // The invoice being paid, so a black-box driver can correlate + // the payment by hash without reading the QR code. + AutomationReadout(AutomationIds.payInvoiceText, + value: lnInvoice), NwcPaymentWidget( lnInvoice: lnInvoice, sats: sats, diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index fe399ba6..24813389 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -80,6 +80,14 @@ class TradeDetailScreen extends ConsumerWidget { const SizedBox(height: 16), // For pending orders created by the user, show creator's reputation if (isPending && isCreator && originalOrder != null) ...[ + // This branch replaces the Mostro message card, which is + // where `order.status` normally lives, so without the + // readout the status disappears from the trade detail of + // the maker's own pending order. + AutomationReadout( + AutomationIds.orderStatus, + value: tradeState.status.value, + ), // TODO: Change this to use `orderPayload` after Order model is updated // with rating information _buildCreatorReputation(context, originalOrder), diff --git a/test/core/automation/automation_contract_test.dart b/test/core/automation/automation_contract_test.dart index 1234b5a6..fa23abeb 100644 --- a/test/core/automation/automation_contract_test.dart +++ b/test/core/automation/automation_contract_test.dart @@ -252,6 +252,18 @@ void main() { expect(semantics.label, 'connected'); expect(semantics.getSemanticsData().label, 'connected'); }); + + testWidgets('an invisible readout still carries id and value', + (tester) async { + // `AutomationReadout` draws nothing, so the node it adds is the only + // thing a driver or a screen reader has to go on. + await tester.pumpWidget( + harness(const AutomationReadout('demo.readout', value: 'pending'))); + + final semantics = + tester.getSemantics(find.bySemanticsIdentifier('demo.readout')); + expect(semantics.getSemanticsData().label, 'pending'); + }); }); group('contract identifiers on real widgets', () { diff --git a/test/features/trades/screens/trade_detail_screen_test.dart b/test/features/trades/screens/trade_detail_screen_test.dart index 572f2b96..770f8d7a 100644 --- a/test/features/trades/screens/trade_detail_screen_test.dart +++ b/test/features/trades/screens/trade_detail_screen_test.dart @@ -3,19 +3,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/core/automation/automation_ids.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/features/order/models/order_state.dart'; import 'package:mostro_mobile/features/order/notifiers/order_notifier.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/trades/screens/trade_detail_screen.dart'; +import 'package:mostro_mobile/features/trades/widgets/mostro_message_detail_widget.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/time_provider.dart'; @@ -60,6 +66,39 @@ class _FixedOrderNotifier extends OrderNotifier { void subscribe() {} } +/// The maker's own session for the order: a seller session on a sell order +/// is what `_isUserCreator` reads as "you created this". +Session _makerSession() => Session( + masterKey: NostrKeyPairs(private: '0' * 63 + '1'), + tradeKey: NostrKeyPairs(private: '0' * 63 + '1'), + keyIndex: 1, + fullPrivacy: false, + startTime: DateTime.utc(2026, 1, 1), + orderId: 'order-1', + role: Role.seller, + ); + +/// The public 38383 the creator reputation card is built from. +NostrEvent _publicOrderEvent() => NostrEvent( + id: 'event-id', + kind: 38383, + content: '', + sig: 'sig', + pubkey: 'a' * 64, + createdAt: DateTime.utc(2026, 1, 1), + tags: const [ + ['d', 'order-1'], + ['k', 'sell'], + ['f', 'CUP'], + ['s', 'pending'], + ['amt', '495'], + ['fa', '333'], + ['pm', 'Saldo movil'], + ['premium', '0'], + ['rating', '{"total_reviews":3,"total_rating":4.5,"days":10}'], + ], + ); + Order _order(Status status) => Order( id: 'order-1', kind: OrderType.sell, @@ -84,9 +123,16 @@ void main() { tearDown(() async => db.close()); - Future pumpDetail(WidgetTester tester, OrderState tradeState) async { + Future pumpDetail( + WidgetTester tester, + OrderState tradeState, { + Session? session, + NostrEvent? publicEvent, + }) async { await tester.pumpWidget(ProviderScope( overrides: [ + sessionProvider(orderId).overrideWith((ref) => session), + eventProvider(orderId).overrideWithValue(publicEvent), sharedPreferencesProvider.overrideWithValue(SharedPreferencesAsync()), mostroDatabaseProvider.overrideWithValue(db), nostrServiceProvider.overrideWithValue(_SilentNostrService()), @@ -163,4 +209,57 @@ void main() { expect(find.byType(CircularProgressIndicator), findsOneWidget); }); }); + + group('order.status on the trade detail', () { + // Regression: on the maker's own pending order the screen swaps the + // Mostro message card — the only widget carrying `order.status` — for the + // creator reputation, so the status vanished from the whole pending phase + // of every order this app creates. See docs/automation-contract.md. + testWidgets('is exposed on a pending order you created', (tester) async { + await pumpDetail( + tester, + OrderState( + status: Status.pending, + action: actions.Action.newOrder, + order: _order(Status.pending), + ), + session: _makerSession(), + publicEvent: _publicOrderEvent(), + ); + + // The branch under test: reputation shown, message card gone. + expect(find.byType(MostroMessageDetail), findsNothing); + + final status = find.bySemanticsIdentifier(AutomationIds.orderStatus); + expect(status, findsOneWidget); + expect( + tester.getSemantics(status).getSemanticsData().label, + Status.pending.value, + ); + }); + + testWidgets('is exposed once on the message card branch', (tester) async { + // The other branch still owns the identifier, and the two never both + // render: a driver always finds exactly one node. + await pumpDetail( + tester, + OrderState( + status: Status.waitingPayment, + action: actions.Action.payInvoice, + order: _order(Status.waitingPayment), + ), + session: _makerSession(), + publicEvent: _publicOrderEvent(), + ); + + expect(find.byType(MostroMessageDetail), findsOneWidget); + + final status = find.bySemanticsIdentifier(AutomationIds.orderStatus); + expect(status, findsOneWidget); + expect( + tester.getSemantics(status).getSemanticsData().label, + Status.waitingPayment.value, + ); + }); + }); }