import 'dart:math' as math; import 'dart:ui' show DisplayFeature, DisplayFeatureState, DisplayFeatureType; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sm_mobile/core/app_controller.dart'; import 'package:sm_mobile/main.dart'; import 'package:sm_mobile/ui/adaptive.dart'; import 'package:sm_mobile/ui/diagnostics_page.dart'; import 'package:sm_mobile/ui/servers_page.dart'; import 'support.dart'; // Breakpoints and the tested range. The range is what the tests sweep, not a // limit of supported devices. const breakpoints = [ WindowClass.mediumMin, WindowClass.expandedMin, WindowClass.largeMin, WindowClass.extraLargeMin, ]; const minWidth = 240.0, maxWidth = 2560.0, minHeight = 320.0, maxHeight = 3000.0; /// Widths: range ends, an even spread, and every breakpoint -1/-0.5/0/+0.5/+1. List generatedWidths({int steps = 12}) => { minWidth, maxWidth, for (var i = 0; i <= steps; i++) (minWidth + (maxWidth - minWidth) * i / steps).roundToDouble(), for (final b in breakpoints) ...[b - 1, b - .5, b, b + .5, b + 1], }.toList()..sort(); List generatedHeights({int steps = 8}) => { minHeight, maxHeight, 479, 480, 481, for (var i = 0; i <= steps; i++) (minHeight + (maxHeight - minHeight) * i / steps).roundToDouble(), }.toList()..sort(); const ratios = [1.0, 1.5, 2.0, 2.625, 3.0, 3.5]; const textScales = [1.0, 1.3, 2.0]; /// Safe areas in logical pixels: none, portrait notch, landscape notch, /// and a status area beside the content. const paddings = [ EdgeInsets.zero, EdgeInsets.only(top: 59, bottom: 34), EdgeInsets.only(left: 59, right: 59, bottom: 21), EdgeInsets.only(right: 44), ]; class Shape { final Size size; final double ratio, text; final EdgeInsets padding; final EdgeInsets keyboard; final List features; const Shape( this.size, { this.ratio = 3, this.text = 1, this.padding = EdgeInsets.zero, this.keyboard = EdgeInsets.zero, this.features = const [], }); @override String toString() => '${size.width}x${size.height}dp @${ratio}x text${text}x ' 'pad$padding kb${keyboard.bottom} features${features.length}'; } /// Every width × height combination, cycling ratio, text size and safe area /// deterministically so that each value meets many window shapes. List generatedShapes() { final out = []; var n = 0; for (final w in generatedWidths()) { for (final h in generatedHeights()) { out.add( Shape( Size(w, h), ratio: ratios[n % ratios.length], text: textScales[(n ~/ 2) % textScales.length], padding: paddings[(n ~/ 3) % paddings.length], ), ); n++; } } return out; } FakeViewPadding fake(EdgeInsets e, double ratio) => FakeViewPadding( left: e.left * ratio, top: e.top * ratio, right: e.right * ratio, bottom: e.bottom * ratio, ); Future apply(WidgetTester tester, Shape s) async { final v = tester.view; v.devicePixelRatio = s.ratio; // Physical pixels = logical size × devicePixelRatio. Layout only ever sees // the logical size. v.physicalSize = Size(s.size.width * s.ratio, s.size.height * s.ratio); v.padding = fake(s.padding, s.ratio); v.viewPadding = fake(s.padding, s.ratio); v.viewInsets = fake(s.keyboard, s.ratio); v.displayFeatures = s.features; tester.platformDispatcher.textScaleFactorTestValue = s.text; await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); } void reset(WidgetTester tester) { tester.view.reset(); tester.platformDispatcher.clearTextScaleFactorTestValue(); } Future openWorkspace(WidgetTester tester) async { final c = AppController(MemoryStore()); await tester.runAsync(() async { await c.initialize(); await c.acceptLicense(); await c.openVault('adaptive layout test password', create: true); }); await tester.pumpWidget(SmMobileApp(c)); await tester.pumpAndSettle(); return c; } Future closeWorkspace(WidgetTester tester, AppController c) async { await tester.pumpWidget(const SizedBox()); c.dispose(); } /// Opens a page through whatever navigation the current layout shows. Future go(WidgetTester tester, IconData icon) async { final nav = find.byWidgetPredicate( (w) => w is NavigationRail || w is NavigationBar, ); final direct = find.descendant(of: nav, matching: find.byIcon(icon)); if (direct.evaluate().isNotEmpty) { await tester.tap(direct.first); } else if (icon == Icons.dns_outlined && find.byType(NavigationRail).evaluate().isNotEmpty) { // Two panes: the server list is always on screen. expect(find.byType(ServersPage), findsOneWidget); return; } else { // The fourth bottom bar item opens the remaining destinations. await tester.tap(find.byType(NavigationDestination).last); await tester.pumpAndSettle(); await tester.tap( find.descendant(of: find.byType(ListTile), matching: find.byIcon(icon)), ); } await tester.pumpAndSettle(); } const pageIcons = [ Icons.dns_outlined, Icons.terminal, Icons.folder_outlined, Icons.monitor_heart_outlined, Icons.network_check, Icons.settings_outlined, ]; DisplayFeature crease( Rect bounds, { DisplayFeatureType type = DisplayFeatureType.fold, DisplayFeatureState state = DisplayFeatureState.postureHalfOpened, }) => DisplayFeature(bounds: bounds, type: type, state: state); void main() { group('window classes', () { test('every breakpoint just below, on and just above', () { expect(WindowClass.ofWidth(0), WindowClass.compact); final classes = WindowClass.values; for (var i = 0; i < breakpoints.length; i++) { final b = breakpoints[i]; for (final d in [1.0, .5, .01]) { expect(WindowClass.ofWidth(b - d), classes[i], reason: '${b - d}'); expect( WindowClass.ofWidth(b + d), classes[i + 1], reason: '${b + d}', ); } expect(WindowClass.ofWidth(b), classes[i + 1], reason: '$b'); } expect(WindowClass.ofWidth(100000), WindowClass.extraLarge); }); test('class grows monotonically over the generated range', () { var last = WindowClass.compact; for (var w = 1.0; w <= 4000; w += 0.5) { final c = WindowClass.ofWidth(w); expect(c.index >= last.index, true, reason: '$w'); last = c; } }); test('class ignores devicePixelRatio: only logical size counts', () { for (final w in generatedWidths()) { final expected = WindowClass.ofWidth(w); for (final r in ratios) { // A physical width divided by its ratio is the same logical width. expect(WindowClass.ofWidth(w * r / r), expected); } } }); test('navigation and pane rules', () { AdaptiveLayout l( double w, double h, { EdgeInsets? pad, List? f, }) => AdaptiveLayout( Size(w, h), padding: pad ?? EdgeInsets.zero, features: f ?? const [], ); expect(l(390, 844).useRail, false); expect(l(844, 390).useRail, true, reason: 'short landscape phone'); expect(l(479, 320).useRail, false, reason: 'too narrow for a rail'); expect(l(568, 320).useRail, true); expect(l(599, 900).useRail, false); expect(l(600, 900).useRail, true); expect(l(500, 900, pad: const EdgeInsets.only(right: 44)).useRail, true); expect(l(839, 1000).twoPane, false); expect(l(840, 1000).twoPane, true); // A flat fold does not separate; a half-opened fold or a hinge does. final flat = crease( const Rect.fromLTWH(420, 0, 0, 900), state: DisplayFeatureState.postureFlat, ); expect(l(840, 900, f: [flat]).separatingFeature, null); final half = crease(const Rect.fromLTWH(300, 0, 0, 900)); expect(l(600, 900, f: [half]).twoPane, true); final hinge = crease( const Rect.fromLTWH(290, 0, 20, 900), type: DisplayFeatureType.hinge, state: DisplayFeatureState.unknown, ); expect(l(600, 900, f: [hinge]).separatingFeature, hinge); final cutout = crease( const Rect.fromLTWH(150, 0, 100, 30), type: DisplayFeatureType.cutout, state: DisplayFeatureState.unknown, ); expect(l(400, 900, f: [cutout]).separatingFeature, null); final table = crease(const Rect.fromLTWH(0, 450, 840, 0)); expect(l(840, 900, f: [table]).horizontalFold, true); expect(l(840, 900, f: [table]).useRail, false); }); }); testWidgets( 'generated sizes 240–2560 × 320–3000 dp on every page, resized live', (tester) async { addTearDown(() => reset(tester)); await apply(tester, const Shape(Size(700, 1000))); final c = await openWorkspace(tester); final shapes = generatedShapes(); final failures = []; var checked = 0; for (var page = 0; page < pageIcons.length; page++) { await apply(tester, const Shape(Size(700, 1000))); await go(tester, pageIcons[page]); // Each page meets the shapes in a different order, so it is resized // from many different previous shapes. final order = [...shapes]..shuffle(math.Random(page)); for (final s in order) { await apply(tester, s); checked++; final error = tester.takeException(); if (error != null) failures.add('page $page $s: $error'); final layout = AdaptiveLayout(s.size, padding: s.padding); final canvas = s.size.width >= minimumCanvas.width && s.size.height >= minimumCanvas.height; if (canvas) { final rail = find.byType(NavigationRail).evaluate().length; final bar = find.byType(NavigationBar).evaluate().length; if (layout.useRail != (rail == 1) || layout.useRail == (bar == 1)) { failures.add('page $page $s: navigation rail=$rail bar=$bar'); } } } } expect(failures, isEmpty, reason: failures.take(20).join('\n')); expect(checked, greaterThan(1000)); await closeWorkspace(tester, c); }, ); testWidgets('every destination is reachable at representative shapes', ( tester, ) async { addTearDown(() => reset(tester)); await apply(tester, const Shape(Size(390, 844))); final c = await openWorkspace(tester); // Shapes below the minimum canvas are covered by their own test. for (final size in const [ Size(320, 568), Size(360, 640), Size(375, 667), Size(390, 844), Size(430, 932), Size(600, 960), Size(768, 1024), Size(820, 1180), Size(1024, 1366), Size(1280, 800), Size(1440, 900), Size(240, 320), Size(2560, 1600), ]) { for (final s in [ size, size.flipped, ].where((s) => s.height >= minimumCanvas.height)) { for (final text in [1.0, 2.0]) { await apply(tester, Shape(s, text: text)); for (var page = 1; page < pageIcons.length; page++) { await go(tester, pageIcons[page]); expect(tester.takeException(), null, reason: '$s page $page'); } } } } await closeWorkspace(tester, c); }); testWidgets('below the minimum canvas the app scrolls instead of breaking', ( tester, ) async { addTearDown(() => reset(tester)); await apply(tester, const Shape(Size(390, 844))); final c = await openWorkspace(tester); for (final s in const [ Size(120, 160), Size(200, 300), Size(239, 319), Size(600, 200), Size(1, 1), ]) { await apply(tester, Shape(s, text: 2)); expect(tester.takeException(), null, reason: '$s'); // Content is laid out on the minimum canvas and remains scrollable. final scaffold = tester.getSize(find.byType(Scaffold).first); expect(scaffold.width, greaterThanOrEqualTo(minimumCanvas.width)); expect(scaffold.height, greaterThanOrEqualTo(minimumCanvas.height)); } await apply(tester, const Shape(Size(390, 844))); expect(tester.getSize(find.byType(Scaffold).first), const Size(390, 844)); await closeWorkspace(tester, c); }); testWidgets('software keyboard over every page and large text', ( tester, ) async { addTearDown(() => reset(tester)); await apply(tester, const Shape(Size(390, 844))); final c = await openWorkspace(tester); for (var page = 0; page < pageIcons.length; page++) { await apply(tester, const Shape(Size(390, 844))); await go(tester, pageIcons[page]); for (final s in const [ Shape(Size(390, 844), keyboard: EdgeInsets.only(bottom: 336)), Shape(Size(320, 568), keyboard: EdgeInsets.only(bottom: 260), text: 2), Shape(Size(844, 390), keyboard: EdgeInsets.only(bottom: 200)), Shape( Size(568, 320), keyboard: EdgeInsets.only(bottom: 200), text: 1.3, ), Shape(Size(1024, 768), keyboard: EdgeInsets.only(bottom: 400)), Shape(Size(768, 1024), keyboard: EdgeInsets.only(bottom: 320), text: 3), ]) { await apply(tester, s); expect(tester.takeException(), null, reason: 'page $page $s'); } } await closeWorkspace(tester, c); }); testWidgets( 'typed data and page state survive every class, rotation and fold', (tester) async { addTearDown(() => reset(tester)); await apply(tester, const Shape(Size(390, 844))); final c = await openWorkspace(tester); // Unsaved input: diagnostics form, and the server search once the list // is shown next to it. await go(tester, Icons.network_check); await tester.enterText( find.byType(TextField).first, 'draft.example.test', ); final diagnostics = tester.state(find.byType(DiagnosticsPage)); State? servers; final sequence = [ for (final w in generatedWidths(steps: 6)) Shape(Size(w, 900)), for (final w in generatedWidths(steps: 6).reversed) Shape(Size(900, w)), const Shape(Size(466, 678)), const Shape(Size(626, 890)), Shape( const Size(626, 890), features: [crease(const Rect.fromLTWH(313, 0, 0, 890))], ), Shape( const Size(890, 626), features: [crease(const Rect.fromLTWH(0, 313, 890, 0))], ), Shape( const Size(900, 700), features: [ crease( const Rect.fromLTWH(440, 0, 20, 700), type: DisplayFeatureType.hinge, state: DisplayFeatureState.unknown, ), ], ), const Shape(Size(390, 844)), ]; for (final s in sequence) { await apply(tester, s); await tester.pump(); expect(tester.takeException(), null, reason: '$s'); expect( tester.state(find.byType(DiagnosticsPage)), same(diagnostics), reason: '$s', ); expect(find.text('draft.example.test'), findsOneWidget, reason: '$s'); final list = find.byType(ServersPage); if (list.evaluate().isNotEmpty) { if (servers == null) { servers = tester.state(list); await tester.enterText( find.descendant(of: list, matching: find.byType(TextField)), 'search-draft', ); await tester.pump(); } // The list may be rebuilt after a fold hid it; the typed search stays. expect(find.text('search-draft'), findsOneWidget, reason: '$s'); } } expect(servers, isNotNull, reason: 'server list was shown in two panes'); await closeWorkspace(tester, c); }, ); testWidgets('server list offers desktop-compatible import and export', ( tester, ) async { addTearDown(() => reset(tester)); for (final size in const [ Size(240, 568), Size(390, 844), Size(1280, 800), ]) { await apply(tester, Shape(size)); final c = await openWorkspace(tester); await tester.tap(find.byIcon(Icons.import_export)); await tester.pumpAndSettle(); expect(find.byIcon(Icons.file_open), findsOneWidget, reason: '$size'); expect(find.byIcon(Icons.ios_share), findsOneWidget, reason: '$size'); // Export with no profiles reports the problem instead of failing. await tester.tap(find.byIcon(Icons.ios_share)); await tester.pumpAndSettle(); expect(find.byType(SnackBar), findsOneWidget, reason: '$size'); expect(tester.takeException(), null); await closeWorkspace(tester, c); } }); testWidgets('a tap on empty space closes the software keyboard', ( tester, ) async { addTearDown(() => reset(tester)); await apply(tester, const Shape(Size(390, 844))); final c = await openWorkspace(tester); await go(tester, Icons.network_check); await tester.tap(find.byType(TextField).first); await tester.pump(); expect(FocusManager.instance.primaryFocus?.context?.widget, isNotNull); expect(tester.testTextInput.isVisible, true); // Non-interactive text in the diagnostics form. await tester.tapAt( tester.getCenter(find.textContaining('TCP ping measures').first), ); await tester.pump(); expect(tester.testTextInput.isVisible, false); await closeWorkspace(tester, c); }); group('folds and hinges', () { Future expectSides( WidgetTester tester, Rect fold, { required bool vertical, }) async { final list = tester.getRect(find.byType(ServersPage)); final tool = tester.getRect(find.byType(DiagnosticsPage)); expect(list.overlaps(fold.inflate(-.01)) && fold.width > 0, false); if (vertical) { expect(list.right, lessThanOrEqualTo(fold.left + .01)); expect(tool.left, greaterThanOrEqualTo(fold.right - .01)); } else { expect(tool.bottom, lessThanOrEqualTo(fold.top + .01)); expect(list.top, greaterThanOrEqualTo(fold.bottom - .01)); } // No tappable control may sit across the fold line. for (final e in find .byWidgetPredicate( (w) => w is ButtonStyleButton || w is IconButton || w is TextField, ) .evaluate()) { final box = e.renderObject! as RenderBox; if (!box.attached || !box.hasSize) continue; final whole = box.localToGlobal(Offset.zero) & box.size; // Only the part visible inside a pane counts; scrolled-away parts are // clipped by the pane. final pane = whole.overlaps(list) ? list : tool; if (!whole.overlaps(pane)) continue; final r = whole.intersect(pane); final crosses = vertical ? r.left < fold.left - .5 && r.right > fold.right + .5 : r.top < fold.top - .5 && r.bottom > fold.bottom + .5; expect( crosses, false, reason: '${e.widget.runtimeType} $r crosses $fold', ); } } for (final ratio in [2.0, 3.0]) { // iPhone Duo: Apple publishes physical pixels only (outer 1398 × 2034, // inner 1878 × 2670). The display scale is not published and no // iPhone Duo simulator is installed, so both common scales are tested // as assumptions; logical size = physical / ratio. final outer = const Size(1398, 2034) / ratio; final inner = const Size(1878, 2670) / ratio; testWidgets( 'iPhone Duo physical resolutions at @${ratio}x: closed, open, half folded, both orientations', (tester) async { addTearDown(() => reset(tester)); await apply(tester, Shape(outer, ratio: ratio)); final c = await openWorkspace(tester); await go(tester, Icons.network_check); expect(tester.view.physicalSize, const Size(1398, 2034)); for (final s in [outer, outer.flipped, inner, inner.flipped, outer]) { await apply( tester, Shape( s, ratio: ratio, padding: const EdgeInsets.only(top: 50, bottom: 20), ), ); expect(tester.takeException(), null, reason: '$s'); expect(WindowClass.ofWidth(s.width), AdaptiveLayout(s).windowClass); } // Half folded: the reserved region in the middle of the inner display, // for a crease along either axis. for (final s in [inner, inner.flipped]) { for (final vertical in [true, false]) { final fold = vertical ? Rect.fromLTWH(s.width / 2 - 8, 0, 16, s.height) : Rect.fromLTWH(0, s.height / 2 - 8, s.width, 16); await apply( tester, Shape(s, ratio: ratio, features: [crease(fold)]), ); await tester.pump(); expect(tester.takeException(), null, reason: '$s $fold'); await expectSides(tester, fold, vertical: vertical); } } await closeWorkspace(tester, c); }, ); } testWidgets('book fold, tabletop fold, hinge and flat fold', ( tester, ) async { addTearDown(() => reset(tester)); await apply(tester, const Shape(Size(673, 841))); final c = await openWorkspace(tester); await go(tester, Icons.network_check); // Closed foldable (narrow outer screen) and open inner screen. for (final s in const [ Size(344, 882), Size(673, 841), Size(841, 673), Size(373, 840), Size(900, 1008), ]) { await apply(tester, Shape(s, ratio: 2.625)); expect(tester.takeException(), null, reason: '$s'); } final book = crease(const Rect.fromLTWH(336, 0, 0, 841)); await apply( tester, Shape(const Size(673, 841), ratio: 2.625, features: [book]), ); await tester.pump(); await expectSides(tester, book.bounds, vertical: true); final table = crease(const Rect.fromLTWH(0, 420, 841, 0)); await apply( tester, Shape(const Size(841, 673), ratio: 2.625, features: [table]), ); await tester.pump(); expect(find.byType(NavigationBar), findsOneWidget); await expectSides(tester, table.bounds, vertical: false); final hinge = crease( const Rect.fromLTWH(540, 0, 34, 720), type: DisplayFeatureType.hinge, state: DisplayFeatureState.unknown, ); await apply( tester, Shape(const Size(1114, 720), ratio: 2.5, features: [hinge]), ); await tester.pump(); await expectSides(tester, hinge.bounds, vertical: true); // A fully open (flat) fold is one continuous surface. await apply( tester, Shape( const Size(673, 841), features: [ crease( const Rect.fromLTWH(336, 0, 0, 841), state: DisplayFeatureState.postureFlat, ), ], ), ); expect(find.byType(ServersPage), findsNothing); expect( tester.getRect(find.byType(DiagnosticsPage)).width, greaterThan(500), ); await closeWorkspace(tester, c); }); }); testWidgets('split screen and small windows on tablets', (tester) async { addTearDown(() => reset(tester)); await apply(tester, const Shape(Size(1194, 834))); final c = await openWorkspace(tester); for (var page = 1; page < pageIcons.length; page++) { await go(tester, pageIcons[page]); // iPad / Android tablet: full, 2/3, 1/2, 1/3 split and slide-over widths, // then free-form resizing step by step. for (final s in [ const Size(1194, 834), const Size(796, 834), const Size(597, 834), const Size(390, 834), const Size(320, 693), const Size(1366, 1024), const Size(678, 1024), const Size(507, 1024), const Size(1280, 800), const Size(640, 800), const Size(1600, 1000), const Size(2560, 1440), for (var w = 300.0; w <= 1400; w += 137) Size(w, 760), ]) { await apply(tester, Shape(s, ratio: 2)); expect(tester.takeException(), null, reason: 'page $page $s'); } } await closeWorkspace(tester, c); }); for (final s in const [ Size(320, 568), Size(390, 844), Size(844, 390), Size(820, 1180), Size(1440, 900), ]) { testWidgets('accessibility guidelines at ${s.width}x${s.height}', ( tester, ) async { addTearDown(() => reset(tester)); final semantics = tester.ensureSemantics(); await apply(tester, Shape(s)); final c = await openWorkspace(tester); for (final mode in [ThemeMode.light, ThemeMode.dark]) { c.theme = mode; c.changed(); await tester.pumpAndSettle(); for (var page = 0; page < pageIcons.length; page++) { await go(tester, pageIcons[page]); await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); await expectLater(tester, meetsGuideline(textContrastGuideline)); } } await closeWorkspace(tester, c); semantics.dispose(); }); } }