tane/apps/app_seeds/test/screenshots/screenshots_test.dart
vjrj f17ae7751c perf(market): paginate Nostr offer discovery, infinite scroll, memory cap
Bound market memory and let large areas page instead of loading everything:
- DiscoveryQuery gains an until cursor; OfferTransport gains discoverPage()
  (one-shot, EOSE-bounded, newest-first) alongside the existing live discover()
  stream, which now accepts since so it only carries NEW offers going forward.
- NostrOfferTransport.discoverPage sorts by created_at desc and derives the
  next cursor from the oldest event seen (not the oldest type-matched one, so
  filtering never skips a page).
- OffersCubit: discover() fetches the first page + opens a since=now live sub;
  loadNextPage() pages further back; the in-memory list is capped at 400
  offers (each can carry a ~40KB inline photo thumbnail, so unbounded growth in
  a busy area was a real OOM risk).
- market_screen: infinite scroll via a scroll-position trigger + footer
  spinner, instead of a single unbounded ListView.
- Added discoverPage unit tests (commons_core) and cubit pagination tests
  (first page/cursor, accumulation, cap, cross-page dedup).
2026-07-20 22:53:48 +02:00

180 lines
7.4 KiB
Dart

@Tags(['screenshots'])
library;
import 'dart:async';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/species_repository.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/state/offers_cubit.dart';
import 'package:tane/state/variety_detail_cubit.dart';
import 'package:tane/ui/calendar_screen.dart';
import 'package:tane/ui/home_screen.dart';
import 'package:tane/ui/inventory_list_screen.dart';
import 'package:tane/ui/market_screen.dart';
import 'package:tane/ui/variety_detail_screen.dart';
import '../support/test_support.dart';
import 'screenshot_support.dart';
/// Generates localized marketing/store screenshots as golden PNGs.
///
/// Run (writes the PNGs) — the `screenshots` tag is skipped by default
/// (dart_test.yaml), so `--run-skipped` is required:
/// flutter test --update-goldens --run-skipped --tags screenshots test/screenshots/screenshots_test.dart
///
/// Output: test/screenshots/goldens/`<locale>`/`<screen>`.png for en/es/fr/de/pt/ja,
/// plus goldens/rtl/ — a right-to-left *layout* demo (English strings mirrored;
/// the app ships no Arabic translation yet, so this proves the design is RTL-safe
/// rather than claiming an Arabic locale). These are raw device-canvas frames
/// with no marketing chrome; add captions/device frames downstream if a store
/// requires them. `tool/collect_screenshots.sh` copies them into site/ and fastlane/.
void main() {
setUpAll(() async {
await loadScreenshotFonts();
// Touch mode never draws focus rings, so no highlight frames the FAB/fields.
FocusManager.instance.highlightStrategy =
FocusHighlightStrategy.alwaysTouch;
});
late AppDatabase db;
late VarietyRepository repo;
late SpeciesRepository species;
setUp(() async {
db = newTestDatabase();
repo = newTestRepository(db);
species = newTestSpeciesRepository(db);
});
tearDown(() => db.close());
Future<void> capture(WidgetTester tester, String dir, String name) async {
tester.view.physicalSize = screenshotSize;
tester.view.devicePixelRatio = screenshotPixelRatio;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpAndSettle();
// Asset images (e.g. the home logo) decode via real async that the fake
// test clock doesn't drive, so they'd paint blank. Precache them for real,
// then repaint, so they show up in the golden.
await tester.runAsync(() async {
for (final element in find.byType(Image).evaluate()) {
await precacheImage((element.widget as Image).image, element);
}
});
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/$dir/$name.png'),
);
await disposeTree(tester);
}
// A POPULATED market: a seeded OffersCubit (no transport) feeding MarketBody,
// so the offer list renders with no live discovery stream to hang
// pumpAndSettle. Offer names are localized. (Live discovery is exercised by
// offers_cubit_test.dart; see market_screen_test.dart on why not via widgets.)
Widget marketWidget(AppLocale locale) => Builder(
builder: (context) => Scaffold(
appBar: AppBar(
title: Text(context.t.market.title),
actions: [IconButton(icon: const Icon(Icons.tune), onPressed: () {})],
),
body: BlocProvider<OffersCubit>(
create: (_) => _SeededOffersCubit(_sampleOffers(locale)),
child: MarketBody(onConfigure: () {}),
),
),
);
const screenNames = ['home', 'inventory', 'market', 'calendar', 'detail'];
for (final locale in screenshotLocales) {
for (final name in screenNames) {
testWidgets('${locale.languageCode}/$name', (tester) async {
// Re-seed per shot so the sample variety names are in the shot's
// language. Drift writes must run in REAL async (runAsync), not the
// testWidgets fake clock, or they hang.
late final SeededInventory seeded;
await tester.runAsync(() async {
seeded = await seedShowcase(db, repo, locale);
});
final child = switch (name) {
'home' => const HomeScreen(marketEnabled: true),
'inventory' => const InventoryListScreen(),
'market' => marketWidget(locale),
'calendar' => const CalendarScreen(initialMonth: 4),
_ => BlocProvider(
create: (_) => VarietyDetailCubit(repo, seeded.heroVarietyId),
child: const VarietyDetailScreen(),
),
};
await tester.pumpWidget(
screenshotApp(
repository: repo,
species: species,
locale: locale,
child: child,
),
);
await capture(tester, locale.languageCode, name);
});
}
}
// RTL layout demo: base (English) strings, forced right-to-left, so the
// mirrored inventory proves the design is RTL-safe. Not a translated locale.
testWidgets('rtl/inventory', (tester) async {
await tester.runAsync(() => seedShowcase(db, repo, AppLocale.en));
await tester.pumpWidget(
screenshotApp(
repository: repo,
species: species,
locale: AppLocale.en,
textDirection: TextDirection.rtl,
child: const InventoryListScreen(),
),
);
await capture(tester, 'rtl', 'inventory');
});
}
/// An [OffersCubit] pre-loaded with a fixed offer list. It carries a no-op
/// transport (never called — offers are pre-seeded) purely so `isOnline` is
/// true and MarketBody renders the list instead of the "can't reach" state.
class _SeededOffersCubit extends OffersCubit {
_SeededOffersCubit(List<Offer> offers) : super(_NoopOfferTransport()) {
emit(state.copyWith(offers: offers, hasSearched: true, areaGeohash: 'sp3e'));
}
}
/// A transport that is present (so the market reads as online) but never used.
class _NoopOfferTransport implements OfferTransport {
@override
Stream<Offer> discover(DiscoveryQuery query, {int? since}) =>
const Stream.empty();
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async =>
const OfferPage(offers: []);
@override
Future<PublishResult> publish(Offer offer) => throw UnimplementedError();
@override
Future<void> retract(String offerId) async {}
@override
Future<void> close() async {}
}
/// A small set of nearby offers across gift / swap / sale and colour-coded
/// botanical families, with names localized to [l].
List<Offer> _sampleOffers(AppLocale l) => [
Offer(id: 'o1', authorPubkeyHex: 'a1' * 32, summary: labelFor('cherryTomato', l), type: OfferType.gift, category: 'Solanaceae', approxGeohash: 'sp3e9', isOrganic: true),
Offer(id: 'o2', authorPubkeyHex: 'a2' * 32, summary: labelFor('bean', l), type: OfferType.exchange, category: 'Fabaceae', approxGeohash: 'sp3e8'),
Offer(id: 'o3', authorPubkeyHex: 'a3' * 32, summary: labelFor('sunflower', l), type: OfferType.sale, category: 'Asteraceae', approxGeohash: 'sp3ec', priceAmount: 2, priceCurrency: ''),
Offer(id: 'o4', authorPubkeyHex: 'a4' * 32, summary: labelFor('basil', l), type: OfferType.gift, category: 'Lamiaceae', approxGeohash: 'sp3e2', isOrganic: true),
Offer(id: 'o5', authorPubkeyHex: 'a5' * 32, summary: labelFor('carrot', l), type: OfferType.exchange, category: 'Apiaceae', approxGeohash: 'sp3ef'),
];