From 2f4d7d395e859f8a3e87170a2bb9f1e227aafa21 Mon Sep 17 00:00:00 2001 From: grunch Date: Tue, 25 Aug 2026 09:09:05 -0300 Subject: [PATCH 1/2] fix: expose order.status on a pending order you created The trade detail swaps the Mostro message card for the creator's reputation on a pending order the user made, and that card is the only place `order.status` lives. The status therefore vanished from the one screen the automation contract names as its home (docs/automation-contract.md), for the whole pending phase of every order this app creates -- a black-box driver reads the maker's own order as having no state at all. A one-pixel readout carries the identifier and the wire status through that branch, the same way `pay.invoice.text` carries the invoice being paid. Nothing changes visually; screen readers get the status. --- lib/features/trades/screens/trade_detail_screen.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index fe399ba6..dceb7024 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -80,6 +80,16 @@ class TradeDetailScreen extends ConsumerWidget { const SizedBox(height: 16), // For pending orders created by the user, show creator's reputation if (isPending && isCreator && originalOrder != null) ...[ + // Automation readout: this branch replaces the Mostro + // message card, which is where `order.status` normally + // lives, so without it the status disappears from the trade + // detail of the maker's own pending order. Invisible; + // screen readers get the wire status. + const SizedBox(width: 1, height: 1).withAutomationId( + AutomationIds.orderStatus, + merge: false, + label: tradeState.status.value, + ), // TODO: Change this to use `orderPayload` after Order model is updated // with rating information _buildCreatorReputation(context, originalOrder), From 5cb4ffd37ec03ec9b417547676bd345cbf66ad0f Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 28 Aug 2026 17:57:51 -0300 Subject: [PATCH 2/2] test: lock order.status to the trade detail on both branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen only had the identifier through MostroMessageDetail, and the maker's own pending order renders the creator reputation instead, so the status was absent for the whole pending phase. Nothing in the suite said so: the gap was found by an external harness. Add a widget test for each branch — it fails without the readout — and give the invisible-readout idiom one definition (AutomationReadout) instead of a second copy, now used by the pay-invoice screen too. The contract doc records that the maker's pending order gets its status from a readout, still exactly one node. --- docs/automation-contract.md | 2 +- lib/core/automation/automation_id.dart | 25 +++++ .../screens/pay_lightning_invoice_screen.dart | 11 +- .../trades/screens/trade_detail_screen.dart | 14 ++- .../automation/automation_contract_test.dart | 12 +++ .../screens/trade_detail_screen_test.dart | 101 +++++++++++++++++- 6 files changed, 148 insertions(+), 17 deletions(-) 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 dceb7024..24813389 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -80,15 +80,13 @@ class TradeDetailScreen extends ConsumerWidget { const SizedBox(height: 16), // For pending orders created by the user, show creator's reputation if (isPending && isCreator && originalOrder != null) ...[ - // Automation readout: this branch replaces the Mostro - // message card, which is where `order.status` normally - // lives, so without it the status disappears from the trade - // detail of the maker's own pending order. Invisible; - // screen readers get the wire status. - const SizedBox(width: 1, height: 1).withAutomationId( + // 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, - merge: false, - label: tradeState.status.value, + value: tradeState.status.value, ), // TODO: Change this to use `orderPayload` after Order model is updated // with rating information 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, + ); + }); + }); }