Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/automation-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<orderId>`, `trades.item.status` | trades | Trade row; status chip whose label is the wire status. |
| `trade.<action>` (`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. |
Expand Down
25 changes: 25 additions & 0 deletions lib/core/automation/automation_id.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
11 changes: 4 additions & 7 deletions lib/features/order/screens/pay_lightning_invoice_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions lib/features/trades/screens/trade_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
12 changes: 12 additions & 0 deletions test/core/automation/automation_contract_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
101 changes: 100 additions & 1 deletion test/features/trades/screens/trade_detail_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -84,9 +123,16 @@ void main() {

tearDown(() async => db.close());

Future<void> pumpDetail(WidgetTester tester, OrderState tradeState) async {
Future<void> 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()),
Expand Down Expand Up @@ -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,
);
});
});
}
Loading