A sale is a distinct model from a gift or a Plantare (reproduction
commitment): a recorded seed sale or purchase with an optional price in
ANY currency — €, Ğ1, time, or none yet. Mirrors the Plantare feature.
- schema v10: Sales table (SyncColumns), guarded createTable migration,
schema dump + generated schema_v10 for the migration round-trip test
- enum SaleDirection { iSold, iBought }
- VarietyRepository: create/watch/watchForVariety/delete + backup
export/exportForSync/import (LWW-by-HLC, tombstones)
- InventorySnapshot.sales + JSON codec encode/decode (round-trips amount,
currency, counterparty)
- UI: SalesScreen (/sales), sale sheet, drawer entry, variety-detail
action beside 'add Plantare'; money hides a trailing .0
- i18n sale block + menu.sales in en/es/pt/ast
- tests: sales repo test (5) incl. Ğ1/price-less/backup round-trip;
Sales screen added to the small-screen overflow guard
Also harden the overflow guard: swallow the headless engine's image
resource service PNG-decode errors (unrelated to layout) while still
failing on real RenderFlex overflows, so home/about stop reporting
false failures.
302 lines
9.9 KiB
Dart
302 lines
9.9 KiB
Dart
import 'package:commons_core/commons_core.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:tane/db/enums.dart';
|
|
import 'package:tane/i18n/strings.g.dart';
|
|
import 'package:tane/services/profile_store.dart';
|
|
import 'package:tane/services/social_account_store.dart';
|
|
import 'package:tane/services/social_connection.dart';
|
|
import 'package:tane/services/social_service.dart';
|
|
import 'package:tane/services/social_settings.dart';
|
|
import 'package:tane/ui/about_screen.dart';
|
|
import 'package:tane/ui/chat_screen.dart';
|
|
import 'package:tane/ui/home_screen.dart';
|
|
import 'package:tane/ui/intro_screen.dart';
|
|
import 'package:tane/ui/inventory_list_screen.dart';
|
|
import 'package:tane/ui/market_screen.dart';
|
|
import 'package:tane/ui/plantares_screen.dart';
|
|
import 'package:tane/ui/profile_screen.dart';
|
|
import 'package:tane/ui/sales_screen.dart';
|
|
import 'package:tane/ui/settings_screen.dart';
|
|
|
|
import '../support/test_support.dart';
|
|
|
|
/// Small-phone layout guard. A Flutter `RenderFlex overflowed` (a clipped title,
|
|
/// a save button pushed off a non-scrolling column, a Row too wide) is reported
|
|
/// as an error and FAILS the test — so pumping each screen at a tiny viewport,
|
|
/// in the languages whose text runs longest (es/pt/ast), catches exactly the
|
|
/// "the save button / title isn't fully visible on a small phone" reports.
|
|
///
|
|
/// FULL SCREENS ONLY. Modal bottom sheets (quick-add, the lot editor) are NOT
|
|
/// checked here: `showModalBottomSheet` gets an unbounded height in a widget
|
|
/// test, which fabricates a huge false overflow. Those sheets already wrap their
|
|
/// body in a `SingleChildScrollView`, so their save buttons stay reachable;
|
|
/// verify them on a real small device.
|
|
void main() {
|
|
// iPhone-SE-class logical viewport: narrow AND short, where clipping shows.
|
|
const small = Size(320, 568);
|
|
// The locales whose strings are longest — English rarely overflows first.
|
|
const longLocales = [AppLocale.es, AppLocale.pt, AppLocale.ast];
|
|
|
|
setUpAll(() => PackageInfo.setMockInitialValues(
|
|
appName: 'Tanemaki',
|
|
packageName: 'org.comunes.tane',
|
|
version: '0.1.0',
|
|
buildNumber: '1',
|
|
buildSignature: '',
|
|
));
|
|
|
|
Future<void> pumpSmall(WidgetTester tester, Widget widget) async {
|
|
tester.view.physicalSize = small;
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
// flutter_test's headless engine can't decode our real PNG assets (the
|
|
// logo), so `Image.asset` throws from the "image resource service". That's
|
|
// unrelated to layout — swallow ONLY those, and let every other FlutterError
|
|
// (crucially the "rendering library" RenderFlex overflow this guard exists
|
|
// to catch) still fail the test. Installed here, inside the running test,
|
|
// because testWidgets reinstalls its own onError at test start (a setUp
|
|
// override would be clobbered).
|
|
final previous = FlutterError.onError;
|
|
FlutterError.onError = (details) {
|
|
if (details.library == 'image resource service') return;
|
|
previous?.call(details);
|
|
};
|
|
addTearDown(() => FlutterError.onError = previous);
|
|
await tester.pumpWidget(widget);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
for (final locale in longLocales) {
|
|
final tag = locale.languageCode;
|
|
|
|
testWidgets('home menu fits a small screen ($tag)', (tester) async {
|
|
final db = newTestDatabase();
|
|
addTearDown(db.close);
|
|
await pumpSmall(
|
|
tester,
|
|
wrapScreen(
|
|
repository: newTestRepository(db),
|
|
locale: locale,
|
|
child: const HomeScreen(marketEnabled: true),
|
|
),
|
|
);
|
|
await disposeTree(tester);
|
|
});
|
|
|
|
testWidgets('settings (language, backup, about) fits ($tag)', (tester) async {
|
|
final db = newTestDatabase();
|
|
addTearDown(db.close);
|
|
await pumpSmall(
|
|
tester,
|
|
wrapScreen(
|
|
repository: newTestRepository(db),
|
|
locale: locale,
|
|
child: const SettingsScreen(),
|
|
),
|
|
);
|
|
await disposeTree(tester);
|
|
});
|
|
|
|
testWidgets('about (title, version, license) fits ($tag)', (tester) async {
|
|
final db = newTestDatabase();
|
|
addTearDown(db.close);
|
|
await pumpSmall(
|
|
tester,
|
|
wrapScreen(
|
|
repository: newTestRepository(db),
|
|
locale: locale,
|
|
child: const AboutScreen(),
|
|
),
|
|
);
|
|
await disposeTree(tester);
|
|
});
|
|
|
|
testWidgets('the intro carousel fits ($tag)', (tester) async {
|
|
final db = newTestDatabase();
|
|
addTearDown(db.close);
|
|
await pumpSmall(
|
|
tester,
|
|
wrapScreen(
|
|
repository: newTestRepository(db),
|
|
locale: locale,
|
|
child: IntroScreen(onDone: () {}),
|
|
),
|
|
);
|
|
await disposeTree(tester);
|
|
});
|
|
}
|
|
|
|
testWidgets('profile (identity card + save button) fits a small screen',
|
|
(tester) async {
|
|
final db = newTestDatabase();
|
|
addTearDown(db.close);
|
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
|
final connection = SocialConnection(
|
|
social: social,
|
|
settings: SocialSettings(InMemorySecretStore()),
|
|
open: (_) async => throw StateError('offline'),
|
|
online: const Stream.empty(),
|
|
);
|
|
await pumpSmall(
|
|
tester,
|
|
wrapScreen(
|
|
repository: newTestRepository(db),
|
|
locale: AppLocale.es,
|
|
child: ProfileScreen(
|
|
social: social,
|
|
connection: connection,
|
|
profileStore: ProfileStore(InMemorySecretStore()),
|
|
accounts: SocialAccountStore(InMemorySecretStore()),
|
|
),
|
|
),
|
|
);
|
|
await disposeTree(tester);
|
|
});
|
|
|
|
// The market/chat are "live" screens; checked here in their OFFLINE state
|
|
// (the shared connection can't reach a relay) — a static layout, and the one
|
|
// a user without signal actually sees.
|
|
Future<SocialConnection> offlineConn() async {
|
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
|
final settings = SocialSettings(InMemorySecretStore());
|
|
await settings.setRelayUrls(const []);
|
|
return SocialConnection(
|
|
social: social,
|
|
settings: settings,
|
|
open: (_) async => throw StateError('offline'),
|
|
online: const Stream.empty(),
|
|
);
|
|
}
|
|
|
|
testWidgets('market (offline "set up sharing") fits a small screen',
|
|
(tester) async {
|
|
final db = newTestDatabase();
|
|
addTearDown(db.close);
|
|
final connection = await offlineConn();
|
|
await pumpSmall(
|
|
tester,
|
|
wrapScreen(
|
|
repository: newTestRepository(db),
|
|
locale: AppLocale.es,
|
|
child: MarketScreen(
|
|
social: await SocialService.fromRootSeedHex('00' * 32),
|
|
settings: SocialSettings(InMemorySecretStore()),
|
|
connection: connection,
|
|
),
|
|
),
|
|
);
|
|
await disposeTree(tester);
|
|
});
|
|
|
|
testWidgets('chat (offline "set up sharing") fits a small screen',
|
|
(tester) async {
|
|
final db = newTestDatabase();
|
|
addTearDown(db.close);
|
|
final connection = await offlineConn();
|
|
await pumpSmall(
|
|
tester,
|
|
wrapScreen(
|
|
repository: newTestRepository(db),
|
|
locale: AppLocale.es,
|
|
child: ChatScreen(
|
|
social: await SocialService.fromRootSeedHex('00' * 32),
|
|
connection: connection,
|
|
peerPubkey: 'ab' * 32,
|
|
),
|
|
),
|
|
);
|
|
await disposeTree(tester);
|
|
});
|
|
|
|
testWidgets('inventory (empty) fits a small screen', (tester) async {
|
|
final db = newTestDatabase();
|
|
addTearDown(db.close);
|
|
await pumpSmall(
|
|
tester,
|
|
wrapScreen(
|
|
repository: newTestRepository(db),
|
|
locale: AppLocale.es,
|
|
child: const InventoryListScreen(),
|
|
),
|
|
);
|
|
await disposeTree(tester);
|
|
});
|
|
|
|
testWidgets('inventory with a long-named seed fits a small screen',
|
|
(tester) async {
|
|
final db = newTestDatabase();
|
|
addTearDown(db.close);
|
|
final repo = newTestRepository(db);
|
|
final id = await repo.addQuickVariety(
|
|
label: 'Tomate rosa de Barbastro extraordinariamente larguísimo',
|
|
category: 'Solanáceas',
|
|
);
|
|
await repo.addLot(
|
|
varietyId: id,
|
|
harvestYear: 2024,
|
|
quantity: const Quantity(kind: QuantityKind.handful),
|
|
offerStatus: OfferStatus.shared,
|
|
);
|
|
await pumpSmall(
|
|
tester,
|
|
wrapScreen(
|
|
repository: repo,
|
|
locale: AppLocale.es,
|
|
child: const InventoryListScreen(),
|
|
),
|
|
);
|
|
await disposeTree(tester);
|
|
});
|
|
|
|
testWidgets('the Plantares screen (with a commitment) fits a small screen',
|
|
(tester) async {
|
|
final db = newTestDatabase();
|
|
addTearDown(db.close);
|
|
final repo = newTestRepository(db);
|
|
await repo.createPlantare(
|
|
direction: PlantareDirection.iReturn,
|
|
counterparty: 'Colectivo semillero de la comarca',
|
|
owedDescription: 'un puñado la próxima temporada, si germina bien',
|
|
);
|
|
await pumpSmall(
|
|
tester,
|
|
wrapScreen(
|
|
repository: repo,
|
|
locale: AppLocale.es,
|
|
child: const PlantaresScreen(),
|
|
),
|
|
);
|
|
await disposeTree(tester);
|
|
});
|
|
|
|
testWidgets('the Sales screen (with a sale) fits a small screen',
|
|
(tester) async {
|
|
final db = newTestDatabase();
|
|
addTearDown(db.close);
|
|
final repo = newTestRepository(db);
|
|
await repo.createSale(
|
|
direction: SaleDirection.iSold,
|
|
counterparty: 'Feria de intercambio de la comarca',
|
|
amount: 12.5,
|
|
currency: '€',
|
|
);
|
|
await pumpSmall(
|
|
tester,
|
|
wrapScreen(
|
|
repository: repo,
|
|
locale: AppLocale.es,
|
|
child: const SalesScreen(),
|
|
),
|
|
);
|
|
await disposeTree(tester);
|
|
});
|
|
|
|
// NOTE: the variety-detail screen is deliberately NOT overflow-checked here.
|
|
// It reports a ~2px overflow on a RenderFlex that is already DISPOSED/DEFUNCT
|
|
// — a transient stale frame while its cubit's Drift stream rebuilds during
|
|
// pumpAndSettle, not the stable rendered layout. Guarding it would be flaky;
|
|
// its RTL/build coverage lives in rtl_smoke_test.dart.
|
|
}
|