diff --git a/.github/workflows/fluttertest.yml b/.github/workflows/fluttertest.yml new file mode 100644 index 00000000..57eae69a --- /dev/null +++ b/.github/workflows/fluttertest.yml @@ -0,0 +1,63 @@ +name: Flutter test +on: + push: + branches: + - main + pull_request: + paths: + - ".github/workflows/fluttercheck.yml" + - "**.dart" + +jobs: + test: + name: Run flutter test + runs-on: macos-26 + strategy: + matrix: + os: [android, ios] + + steps: + - name: Check out code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #6.0.2 + with: + show-progress: false + + # TODO: remove when https://go-review.googlesource.com/c/go/+/692935 lands in mainline go + - name: Patch Go + uses: DefinedNet/patch-go@c8e4a17c75eb242d34c212419d7d3d25e8d58949 + with: + patch-ref: "296be05a97eb526dc0e438b7387670d4cae4a935" + + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0 + with: + distribution: "zulu" + java-version: "17" + cache: "gradle" + + - name: Install flutter + uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e #v2.21.0 + with: + flutter-version: "3.38.9" + cache: true + + - name: install dependencies + env: + TOKEN: ${{ secrets.MACHINE_USER_PAT }} + run: | + go install golang.org/x/mobile/cmd/gomobile@latest + gomobile init + flutter pub get + touch env.sh + + - name: generate artifacts + run: ./gen-artifacts.sh ${{ matrix.os }} + + - name: Run tests + run: flutter test --coverage + + - name: Upload coverage artifacts + uses: actions/upload-artifact@v4 + with: + name: coverage-report-${{ matrix.os }} + path: coverage/lcov.info + if: always() diff --git a/.gitignore b/.gitignore index e5ab3743..08638049 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,11 @@ android/app/.cxx # Web related lib/generated_plugin_registrant.dart +# Test coverage +coverage/ +test/.test_coverage.dart +*.coverage + # Exceptions to above rules. !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages /nebula/local.settings diff --git a/README.md b/README.md index 6c64a5dd..e8b119ff 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Ensure your path is set up correctly to execute flutter Run `flutter doctor` and fix everything it complains before proceeding -*NOTE* on iOS, always open `Runner.xcworkspace` and NOT the `Runner.xccodeproj` +_NOTE_ on iOS, always open `Runner.xcworkspace` and NOT the `Runner.xccodeproj` ### Before first compile @@ -30,17 +30,50 @@ Run `flutter doctor` and fix everything it complains before proceeding If you are having issues with iOS pods, try blowing it all away! `cd ios && rm -rf Pods/ Podfile.lock && pod install --repo-update` +## Testing + +Run all tests: + +```sh +flutter test +``` + +Run specific test file: + +```sh +flutter test test/screens/settings_screen_test.dart +``` + +Generate coverage report: + +```sh +flutter test --coverage +``` + +Run golden tests (update with `--update-goldens` flag): + +```sh +flutter test test/screens/*_golden_test.dart +``` + +## Linting + +```sh +flutter analyze +``` + # Formatting -`dart format` can be used to format the code in `lib` and `test`. We use a line-length of 120 characters. +`dart format` can be used to format the code in `lib` and `test`. We use a line-length of 120 characters. Use: + ```sh dart format lib/ test/ -l 120 ``` -In Android Studio, set the line length using Preferences -> Editor -> Code Style -> Dart -> Line length, set it to 120. Enable auto-format with Preferences -> Languages & Frameworks -> Flutter -> Format code on save. +In Android Studio, set the line length using Preferences -> Editor -> Code Style -> Dart -> Line length, set it to 120. Enable auto-format with Preferences -> Languages & Frameworks -> Flutter -> Format code on save. `./swift-format.sh` can be used to format Swift code in the repo. -Once `swift-format` supports ignoring directories (), we can move to a method of running it more like what describes. \ No newline at end of file +Once `swift-format` supports ignoring directories (), we can move to a method of running it more like what describes. diff --git a/ios/.gitignore b/ios/.gitignore index d7108beb..8d26aec8 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -22,6 +22,7 @@ Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ Flutter/flutter_export_environment.sh +Flutter/ephemeral ServiceDefinitions.json Runner/GeneratedPluginRegistrant.* diff --git a/pubspec.lock b/pubspec.lock index 4719e24c..3d44b182 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -117,18 +125,18 @@ packages: dependency: transitive description: name: cross_file - sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" url: "https://pub.dev" source: hosted - version: "0.3.4+2" + version: "0.3.5+2" crypto: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" csslib: dependency: transitive description: @@ -173,10 +181,10 @@ packages: dependency: transitive description: name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.5" file: dependency: transitive description: @@ -226,10 +234,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 url: "https://pub.dev" source: hosted - version: "2.0.24" + version: "2.0.33" flutter_svg: dependency: "direct main" description: @@ -280,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.0.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" html: dependency: transitive description: @@ -356,10 +372,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.10.0" leak_tracker: dependency: transitive description: @@ -456,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.1.4" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" node_preamble: dependency: transitive description: @@ -464,6 +488,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_config: dependency: transitive description: @@ -524,18 +556,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e url: "https://pub.dev" source: hosted - version: "2.2.15" + version: "2.2.22" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -564,10 +596,10 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "7.0.1" platform: dependency: transitive description: @@ -596,10 +628,10 @@ packages: dependency: transitive description: name: process - sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "5.0.5" properties: dependency: transitive description: @@ -745,10 +777,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" stack_trace: dependency: transitive description: @@ -777,10 +809,10 @@ packages: dependency: transitive description: name: system_info2 - sha256: "65206bbef475217008b5827374767550a5420ce70a04d2d7e94d1d2253f3efc9" + sha256: b937736ecfa63c45b10dde1ceb6bb30e5c0c340e14c441df024150679d65ac43 url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.1.0" tar: dependency: transitive description: @@ -841,34 +873,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" url: "https://pub.dev" source: hosted - version: "6.3.14" + version: "6.3.28" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" + sha256: b1aca26728b7cc7a3af971bb6f601554a8ae9df2e0a006de8450ba06a17ad36a url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.4.0" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -881,18 +913,18 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.2" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" uuid: dependency: "direct main" description: @@ -905,10 +937,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "27d5fefe86fb9aace4a9f8375b56b3c292b64d8c04510df230f849850d912cb7" + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 url: "https://pub.dev" source: hosted - version: "1.1.15" + version: "1.1.19" vector_graphics_codec: dependency: transitive description: @@ -921,10 +953,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" + sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" url: "https://pub.dev" source: hosted - version: "1.1.16" + version: "1.1.20" vector_math: dependency: transitive description: @@ -937,10 +969,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.0.2" watcher: dependency: transitive description: @@ -953,10 +985,10 @@ packages: dependency: transitive description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web_socket: dependency: transitive description: @@ -985,10 +1017,10 @@ packages: dependency: transitive description: name: win32 - sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.10.0" + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -1001,10 +1033,10 @@ packages: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" yaml: dependency: transitive description: @@ -1015,4 +1047,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.10.8 <4.0.0" - flutter: ">=3.38.0" + flutter: ">=3.38.4" diff --git a/test/components/site_item_test.dart b/test/components/site_item_test.dart new file mode 100644 index 00000000..bfe90982 --- /dev/null +++ b/test/components/site_item_test.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_nebula/components/site_item.dart'; + +import '../test_helpers.dart'; + +/// Helper to find text within RichText widgets +Finder findRichText(String text) { + return find.byWidgetPredicate((widget) { + if (widget is! RichText) return false; + + String extractText(InlineSpan span) { + if (span is TextSpan) { + final buffer = StringBuffer(); + if (span.text != null) buffer.write(span.text); + span.children?.forEach((child) { + buffer.write(extractText(child)); + }); + return buffer.toString(); + } + return ''; + } + + return extractText(widget.text).contains(text); + }); +} + +void main() { + group('SiteItem Widget Tests', () { + testWidgets('displays site name correctly', (WidgetTester tester) async { + final site = createMockSite(name: 'Test Site'); + + await tester.pumpWidget(createTestApp(child: SiteItem(site: site))); + + expect(findRichText('Test Site'), findsOneWidget); + }); + + testWidgets('displays managed badge for managed sites', (WidgetTester tester) async { + final site = createMockSite(name: 'Test Managed Site', managed: true); + + await tester.pumpWidget(createTestApp(child: SiteItem(site: site))); + + expect(findRichText('Test Managed Site'), findsOneWidget); + expect(find.text('Managed'), findsOneWidget); + }); + + testWidgets('does not display managed badge for unmanaged sites', (WidgetTester tester) async { + final site = createMockSite(name: 'Test Unmanaged Site', managed: false); + + await tester.pumpWidget(createTestApp(child: SiteItem(site: site))); + + expect(findRichText('Test Unmanaged Site'), findsOneWidget); + expect(find.text('Managed'), findsNothing); + }); + + testWidgets('displays site status when connected', (WidgetTester tester) async { + final site = createMockSite(name: 'Test Connected Site', connected: true, status: 'Connected'); + + await tester.pumpWidget(createTestApp(child: SiteItem(site: site))); + + expect(findRichText('Test Connected Site'), findsOneWidget); + expect(find.text('Connected'), findsOneWidget); + }); + + testWidgets('displays "Resolve errors" when site has errors', (WidgetTester tester) async { + final site = createMockSite(name: 'Error Site', errors: ['Certificate expired']); + + await tester.pumpWidget(createTestApp(child: SiteItem(site: site))); + + expect(find.text('Resolve errors'), findsOneWidget); + expect(find.byIcon(Icons.warning_rounded), findsOneWidget); + }); + + testWidgets('switch reflects connection state', (WidgetTester tester) async { + final site = createMockSite(connected: true); + + await tester.pumpWidget(createTestApp(child: SiteItem(site: site))); + + final switchFinder = find.byType(Switch); + expect(switchFinder, findsOneWidget); + + final switchWidget = tester.widget(switchFinder); + expect(switchWidget.value, isTrue); + }); + + testWidgets('switch is disabled when site has errors and is disconnected', (WidgetTester tester) async { + final site = createMockSite(connected: false, errors: ['Some error']); + + await tester.pumpWidget(createTestApp(child: SiteItem(site: site))); + + final switchFinder = find.byType(Switch); + final switchWidget = tester.widget(switchFinder); + expect(switchWidget.onChanged, isNull); + }); + + testWidgets('switch is enabled when site has errors but is connected', (WidgetTester tester) async { + final site = createMockSite(connected: true, errors: ['Some error']); + + await tester.pumpWidget(createTestApp(child: SiteItem(site: site))); + + final switchFinder = find.byType(Switch); + final switchWidget = tester.widget(switchFinder); + expect(switchWidget.onChanged, isNotNull); + }); + + testWidgets('switch is enabled when site has no errors', (WidgetTester tester) async { + final site = createMockSite(connected: false, errors: []); + + await tester.pumpWidget(createTestApp(child: SiteItem(site: site))); + + final switchFinder = find.byType(Switch); + final switchWidget = tester.widget(switchFinder); + expect(switchWidget.onChanged, isNotNull); + }); + + testWidgets('displays Details button', (WidgetTester tester) async { + final site = createMockSite(name: 'Test Site'); + + await tester.pumpWidget(createTestApp(child: SiteItem(site: site))); + + expect(find.text('Details'), findsOneWidget); + }); + + testWidgets('calls onPressed when Details is tapped', (WidgetTester tester) async { + final site = createMockSite(name: 'Test Site'); + bool wasPressed = false; + + await tester.pumpWidget( + createTestApp( + child: SiteItem(site: site, onPressed: () => wasPressed = true), + ), + ); + + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + + expect(wasPressed, isTrue); + }); + + testWidgets('badge uses theme colors', (WidgetTester tester) async { + final site = createMockSite(name: 'Managed Site', managed: true); + + await tester.pumpWidget(createTestApp(child: SiteItem(site: site))); + + // Verify badge is rendered + expect(find.text('Managed'), findsOneWidget); + + // Find the Container with badge decoration + final badgeContainer = tester.widget( + find.ancestor(of: find.text('Managed'), matching: find.byType(Container)).first, + ); + + expect(badgeContainer.decoration, isA()); + final decoration = badgeContainer.decoration as BoxDecoration; + expect(decoration.color, isNotNull); + expect(decoration.borderRadius, isNotNull); + }); + + testWidgets('status text uses correct styling', (WidgetTester tester) async { + final site = createMockSite(status: 'Disconnected'); + + await tester.pumpWidget(createTestApp(child: SiteItem(site: site))); + + final statusText = tester.widget(find.text('Disconnected')); + expect(statusText.style?.fontSize, equals(14)); + expect(statusText.style?.fontWeight, equals(FontWeight.w500)); + }); + + testWidgets('site name uses correct styling', (WidgetTester tester) async { + final site = createMockSite(name: 'Styled Site'); + + await tester.pumpWidget(createTestApp(child: SiteItem(site: site))); + + // Find the RichText widget that contains the site name + final richTextFinder = findRichText('Styled Site'); + expect(richTextFinder, findsOneWidget); + + final richText = tester.widget(richTextFinder); + final textSpan = richText.text as TextSpan; + + // Recursively find TextSpan with the site name + TextSpan? findTextSpan(InlineSpan span, String text) { + if (span is TextSpan) { + if (span.text == text) return span; + if (span.children != null) { + for (var child in span.children!) { + final found = findTextSpan(child, text); + if (found != null) return found; + } + } + } + return null; + } + + final siteNameSpan = findTextSpan(textSpan, 'Styled Site'); + expect(siteNameSpan, isNotNull); + expect(siteNameSpan!.style?.fontSize, equals(16)); + expect(siteNameSpan.style?.fontWeight, equals(FontWeight.w500)); + }); + }); +} diff --git a/test/screens/settings_screen_test.dart b/test/screens/settings_screen_test.dart new file mode 100644 index 00000000..1e1407b8 --- /dev/null +++ b/test/screens/settings_screen_test.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_nebula/screens/settings_screen.dart' + show badDebugSave, goodDebugSave, goodDebugSaveV2, SettingsScreen; + +void main() { + group('SettingsScreen Widget Tests', () { + late StreamController testStream; + + setUp(() { + testStream = StreamController.broadcast(); + }); + + tearDown(() { + testStream.close(); + }); + + group('useSystemColors behavior', () { + testWidgets('dark mode toggle visibility depends on useSystemColors', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: SettingsScreen(testStream, null))); + await tester.pumpAndSettle(); + + // Verify useSystemColors switch exists + expect(find.text('Use system colors'), findsOneWidget); + final useSystemColorsSwitch = find.byType(Switch).first; + expect(useSystemColorsSwitch, findsOneWidget); + + // Get the current state to verify conditional rendering logic + final switchWidget = tester.widget(useSystemColorsSwitch); + final isDarkModeVisible = find.text('Dark mode').evaluate().isNotEmpty; + + // Verify the logical relationship: if using system colors, dark mode should be hidden + // This verifies the conditional rendering in SettingsScreen lines 138-154 + if (switchWidget.value == true) { + expect(isDarkModeVisible, isFalse, reason: 'Dark mode toggle should be hidden when using system colors'); + } + // If not using system colors, dark mode can be visible (but not guaranteed due to async loading) + }); + }); + + group('Switch interactions', () { + testWidgets('useSystemColors switch is tappable', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: SettingsScreen(testStream, null))); + await tester.pumpAndSettle(); + + // Verify switch exists and is tappable + final switchFinder = find.byType(Switch).first; + expect(switchFinder, findsOneWidget); + + // Verify it has an onChanged callback (is interactive) + final switchWidget = tester.widget(switchFinder); + expect(switchWidget.onChanged, isNotNull, reason: 'Switch should be interactive'); + + // Verify tapping doesn't crash + await tester.tap(switchFinder); + await tester.pumpAndSettle(); + }); + + testWidgets('logWrap switch is tappable', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: SettingsScreen(testStream, null))); + await tester.pumpAndSettle(); + + expect(find.text('Wrap log output'), findsOneWidget); + + // Find all switches + final switches = find.byType(Switch); + expect( + switches.evaluate().length, + greaterThanOrEqualTo(2), + reason: 'Should have at least useSystemColors and logWrap switches', + ); + + // Verify tapping doesn't crash + final logWrapSwitch = switches.at(1); + await tester.tap(logWrapSwitch); + await tester.pumpAndSettle(); + }); + + testWidgets('trackErrors switch is tappable', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: SettingsScreen(testStream, null))); + await tester.pumpAndSettle(); + + expect(find.text('Report errors automatically'), findsOneWidget); + + // Find all switches + final switches = find.byType(Switch); + expect( + switches.evaluate().length, + greaterThanOrEqualTo(3), + reason: 'Should have at least useSystemColors, logWrap, and trackErrors switches', + ); + + // Verify tapping doesn't crash (trackErrors is typically index 2) + final trackErrorsSwitch = switches.at(2); + await tester.tap(trackErrorsSwitch); + await tester.pumpAndSettle(); + }); + }); + + group('UI elements present', () { + testWidgets('displays all expected settings options', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: SettingsScreen(testStream, null))); + await tester.pumpAndSettle(); + + // Verify core settings are present + expect(find.text('Use system colors'), findsOneWidget); + expect(find.text('Wrap log output'), findsOneWidget); + expect(find.text('Report errors automatically'), findsOneWidget); + expect(find.text('Enroll with Managed Nebula'), findsOneWidget); + expect(find.text('About'), findsOneWidget); + }); + + testWidgets('all switches are interactive', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: SettingsScreen(testStream, null))); + await tester.pumpAndSettle(); + + // Verify we have interactive switches + final switches = find.byType(Switch); + expect(switches, findsWidgets); + + // Verify switches have onChanged callbacks (are interactive) + for (final element in switches.evaluate()) { + final switchWidget = element.widget as Switch; + expect(switchWidget.onChanged, isNotNull, reason: 'Switch should be interactive'); + } + }); + }); + + group('Debug functionality', () { + testWidgets('displays debug buttons in debug mode', (WidgetTester tester) async { + if (!kDebugMode) return; + + await tester.pumpWidget(MaterialApp(home: SettingsScreen(testStream, null))); + await tester.pumpAndSettle(); + + expect(find.text('Bad Site'), findsOneWidget); + expect(find.text('Good Site'), findsOneWidget); + expect(find.text('Good Site V2'), findsOneWidget); + expect(find.text('Clear Keys'), findsOneWidget); + }); + + testWidgets('accepts debug callback parameter', (WidgetTester tester) async { + if (!kDebugMode) return; + + await tester.pumpWidget(MaterialApp(home: SettingsScreen(testStream, () => null))); + await tester.pumpAndSettle(); + + // Verify debug buttons render with callback provided + expect(find.text('Bad Site'), findsOneWidget); + // Note: Actually testing the callback requires platform channel mocking + }); + }); + }); + + group('SettingsScreen Debug Constants', () { + test('badDebugSave contains required fields', () { + expect(badDebugSave['name'], equals('Bad Site')); + expect(badDebugSave['cert'], isNotEmpty); + expect(badDebugSave['key'], isNotEmpty); + expect(badDebugSave['ca'], isNotEmpty); + }); + + test('goodDebugSave contains required fields', () { + expect(goodDebugSave['name'], equals('Good Site')); + expect(goodDebugSave['cert'], isNotEmpty); + expect(goodDebugSave['key'], isNotEmpty); + expect(goodDebugSave['ca'], isNotEmpty); + }); + + test('goodDebugSaveV2 contains required fields', () { + expect(goodDebugSaveV2['name'], equals('Good Site V2')); + expect(goodDebugSaveV2['cert'], contains('V2')); + expect(goodDebugSaveV2['key'], isNotEmpty); + expect(goodDebugSaveV2['ca'], contains('V2')); + }); + }); +} diff --git a/test/services/theme_test.dart b/test/services/theme_test.dart new file mode 100644 index 00000000..bdafa73c --- /dev/null +++ b/test/services/theme_test.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_nebula/services/theme.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +void main() { + group('MaterialTheme Tests', () { + late MaterialTheme theme; + + setUp(() { + final textTheme = Typography.material2021().black; + theme = MaterialTheme(textTheme); + }); + + group('Light Theme', () { + test('creates light theme', () { + final lightTheme = theme.light(); + + expect(lightTheme, isNotNull); + expect(lightTheme.brightness, equals(Brightness.light)); + expect(lightTheme.useMaterial3, isTrue); + }); + + test('light theme has badge theme configured', () { + final lightTheme = theme.light(); + + expect(lightTheme.badgeTheme, isNotNull); + expect(lightTheme.badgeTheme.backgroundColor, isNotNull); + expect(lightTheme.badgeTheme.textColor, isNotNull); + expect(lightTheme.badgeTheme.textStyle, isNotNull); + }); + + test('light theme badge has correct text style', () { + final lightTheme = theme.light(); + final badgeTextStyle = lightTheme.badgeTheme.textStyle; + + expect(badgeTextStyle?.fontWeight, equals(FontWeight.w500)); + expect(badgeTextStyle?.fontSize, equals(12)); + }); + + test('light theme has custom primary container color', () { + final lightTheme = theme.light(); + + // Verify custom primaryContainer color (white) + expect(lightTheme.colorScheme.primaryContainer, equals(const Color.fromRGBO(255, 255, 255, 1))); + }); + + test('light theme has custom secondary container color', () { + final lightTheme = theme.light(); + + // Verify custom onSecondaryContainer color + expect(lightTheme.colorScheme.onSecondaryContainer, equals(const Color.fromRGBO(138, 151, 168, 1))); + }); + + test('light theme has custom surface color', () { + final lightTheme = theme.light(); + + // Verify custom surface color + expect(lightTheme.colorScheme.surface, equals(const Color.fromARGB(255, 226, 229, 233))); + }); + }); + + group('Dark Theme', () { + test('creates dark theme', () { + final darkTheme = theme.dark(); + + expect(darkTheme, isNotNull); + expect(darkTheme.brightness, equals(Brightness.dark)); + expect(darkTheme.useMaterial3, isTrue); + }); + + test('dark theme has badge theme configured', () { + final darkTheme = theme.dark(); + + expect(darkTheme.badgeTheme, isNotNull); + expect(darkTheme.badgeTheme.backgroundColor, isNotNull); + expect(darkTheme.badgeTheme.textColor, isNotNull); + expect(darkTheme.badgeTheme.textStyle, isNotNull); + }); + + test('dark theme badge uses different colors than light', () { + final lightTheme = theme.light(); + final darkTheme = theme.dark(); + + expect(lightTheme.badgeTheme.backgroundColor, isNot(equals(darkTheme.badgeTheme.backgroundColor))); + }); + + test('dark theme has custom primary container color', () { + final darkTheme = theme.dark(); + + // Verify custom primaryContainer color for dark mode + expect(darkTheme.colorScheme.primaryContainer, equals(const Color.fromRGBO(43, 50, 59, 1))); + }); + + test('dark theme has custom surface color', () { + final darkTheme = theme.dark(); + + // Verify custom surface color for dark mode + expect(darkTheme.colorScheme.surface, equals(const Color.fromARGB(255, 22, 25, 29))); + }); + + test('dark theme badge has purple background', () { + final darkTheme = theme.dark(); + + expect(darkTheme.badgeTheme.backgroundColor, equals(const Color.fromARGB(255, 93, 34, 221))); + }); + + test('dark theme badge has light text color', () { + final darkTheme = theme.dark(); + + expect(darkTheme.badgeTheme.textColor, equals(const Color.fromARGB(255, 223, 211, 248))); + }); + }); + + group('Medium Contrast Themes', () { + test('creates light medium contrast theme', () { + final lightMedium = theme.lightMediumContrast(); + + expect(lightMedium, isNotNull); + expect(lightMedium.brightness, equals(Brightness.light)); + }); + + test('creates dark medium contrast theme', () { + final darkMedium = theme.darkMediumContrast(); + + expect(darkMedium, isNotNull); + expect(darkMedium.brightness, equals(Brightness.dark)); + }); + }); + + group('High Contrast Themes', () { + test('creates light high contrast theme', () { + final lightHigh = theme.lightHighContrast(); + + expect(lightHigh, isNotNull); + expect(lightHigh.brightness, equals(Brightness.light)); + }); + + test('creates dark high contrast theme', () { + final darkHigh = theme.darkHighContrast(); + + expect(darkHigh, isNotNull); + expect(darkHigh.brightness, equals(Brightness.dark)); + }); + }); + + group('Color Schemes', () { + test('light scheme has expected colors', () { + final scheme = MaterialTheme.lightScheme(); + + expect(scheme.brightness, equals(Brightness.light)); + expect(scheme.primary, isNotNull); + expect(scheme.onPrimary, isNotNull); + expect(scheme.secondary, isNotNull); + expect(scheme.error, isNotNull); + }); + + test('dark scheme has expected colors', () { + final scheme = MaterialTheme.darkScheme(); + + expect(scheme.brightness, equals(Brightness.dark)); + expect(scheme.primary, isNotNull); + expect(scheme.onPrimary, isNotNull); + expect(scheme.secondary, isNotNull); + expect(scheme.error, isNotNull); + }); + + test('light and dark schemes have different colors', () { + final light = MaterialTheme.lightScheme(); + final dark = MaterialTheme.darkScheme(); + + expect(light.primary, isNot(equals(dark.primary))); + expect(light.surface, isNot(equals(dark.surface))); + }); + }); + + group('Text Theme Integration', () { + test('theme applies text theme correctly', () { + final lightTheme = theme.light(); + + expect(lightTheme.textTheme, isNotNull); + expect(lightTheme.textTheme.bodyLarge, isNotNull); + expect(lightTheme.textTheme.bodyMedium, isNotNull); + expect(lightTheme.textTheme.bodySmall, isNotNull); + }); + }); + + group('Badge Theme Consistency', () { + test('all theme variants have badge theme', () { + expect(theme.light().badgeTheme, isNotNull); + expect(theme.dark().badgeTheme, isNotNull); + expect(theme.lightMediumContrast().badgeTheme, isNotNull); + expect(theme.darkMediumContrast().badgeTheme, isNotNull); + expect(theme.lightHighContrast().badgeTheme, isNotNull); + expect(theme.darkHighContrast().badgeTheme, isNotNull); + }); + + test('all badge themes have consistent text style properties', () { + final themes = [ + theme.light(), + theme.dark(), + theme.lightMediumContrast(), + theme.darkMediumContrast(), + theme.lightHighContrast(), + theme.darkHighContrast(), + ]; + + for (final t in themes) { + final textStyle = t.badgeTheme.textStyle; + expect(textStyle?.fontWeight, equals(FontWeight.w500)); + expect(textStyle?.fontSize, equals(12)); + } + }); + }); + }); + + group('Utils.createTextTheme Integration', () { + testWidgets('creates text theme with Inter font', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final textTheme = Utils.createTextTheme(context, "Inter", "Inter"); + expect(textTheme, isNotNull); + expect(textTheme.bodyLarge, isNotNull); + expect(textTheme.bodyMedium, isNotNull); + expect(textTheme.displayLarge, isNotNull); + return Container(); + }, + ), + ), + ); + }); + }); +} diff --git a/test/services/utils_test.dart b/test/services/utils_test.dart new file mode 100644 index 00000000..46da8138 --- /dev/null +++ b/test/services/utils_test.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_nebula/main.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +void main() { + group('Utils.popError Tests', () { + testWidgets('popError shows dialog with title and error message', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: const Scaffold(body: Center(child: Text('Home'))), + ), + ); + + // Call popError + Utils.popError('Test Error', 'This is a test error message'); + await tester.pumpAndSettle(); + + // Verify dialog is shown + expect(find.text('Test Error'), findsOneWidget); + expect(find.text('This is a test error message'), findsOneWidget); + expect(find.text('Ok'), findsOneWidget); + }); + + testWidgets('popError includes stack trace when provided', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: const Scaffold(body: Center(child: Text('Home'))), + ), + ); + + final stackTrace = StackTrace.current; + Utils.popError('Stack Error', 'Error with stack', stack: stackTrace); + await tester.pumpAndSettle(); + + // Verify error message contains stack trace + expect(find.text('Stack Error'), findsOneWidget); + expect(find.textContaining('Error with stack'), findsOneWidget); + }); + + testWidgets('popError dialog can be dismissed', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: const Scaffold(body: Center(child: Text('Home'))), + ), + ); + + Utils.popError('Dismissible Error', 'This can be dismissed'); + await tester.pumpAndSettle(); + + // Dialog should be visible + expect(find.text('Dismissible Error'), findsOneWidget); + + // Tap OK button + await tester.tap(find.text('Ok')); + await tester.pumpAndSettle(); + + // Dialog should be dismissed + expect(find.text('Dismissible Error'), findsNothing); + }); + + testWidgets('popError handles null navigator context gracefully', (WidgetTester tester) async { + // Note: This test is challenging because we can't easily set navigatorKey.currentContext to null + // In a real scenario, this would require the error to be called before the app is built + // We can at least verify it doesn't crash when called with a valid context + + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: const Scaffold(body: Center(child: Text('Home'))), + ), + ); + + // Calling with valid context should work + expect(() => Utils.popError('Test', 'Message'), returnsNormally); + await tester.pumpAndSettle(); + + // Clean up the dialog + await tester.tap(find.text('Ok')); + await tester.pumpAndSettle(); + }); + + testWidgets('popError works with both dialog types', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: const Scaffold(body: Center(child: Text('Home'))), + ), + ); + + Utils.popError('Dialog Error', 'This is a dialog'); + await tester.pumpAndSettle(); + + // Should find either AlertDialog or CupertinoAlertDialog depending on platform + // On macOS tests, it will be CupertinoAlertDialog + expect(find.text('Dialog Error'), findsOneWidget); + expect(find.text('Ok'), findsOneWidget); + }); + + testWidgets('multiple popError calls show multiple dialogs', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: const Scaffold(body: Center(child: Text('Home'))), + ), + ); + + // First error + Utils.popError('Error 1', 'First error message'); + await tester.pumpAndSettle(); + expect(find.text('Error 1'), findsOneWidget); + + // Dismiss first error + await tester.tap(find.text('Ok')); + await tester.pumpAndSettle(); + + // Second error + Utils.popError('Error 2', 'Second error message'); + await tester.pumpAndSettle(); + expect(find.text('Error 2'), findsOneWidget); + }); + }); + + group('Utils.launchUrl Tests', () { + testWidgets('launchUrl handles invalid URLs gracefully', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: const Scaffold(body: Center(child: Text('Home'))), + ), + ); + + // Try to launch an invalid URL + // Note: In a real test, we'd need to mock url_launcher + // For now, we just ensure the function exists and can be called + expect(() => Utils.launchUrl(''), returnsNormally); + }); + }); + + group('Utils.textSize Tests', () { + test('calculates text size correctly', () { + const text = 'Hello World'; + const style = TextStyle(fontSize: 16); + + final size = Utils.textSize(text, style); + + expect(size.width, greaterThan(0)); + expect(size.height, greaterThan(0)); + }); + + test('larger font size results in larger dimensions', () { + const text = 'Test'; + const smallStyle = TextStyle(fontSize: 12); + const largeStyle = TextStyle(fontSize: 24); + + final smallSize = Utils.textSize(text, smallStyle); + final largeSize = Utils.textSize(text, largeStyle); + + expect(largeSize.width, greaterThan(smallSize.width)); + expect(largeSize.height, greaterThan(smallSize.height)); + }); + }); + + group('Utils.itemCountFormat Tests', () { + test('formats single item correctly', () { + expect(Utils.itemCountFormat(1), equals('1 item')); + }); + + test('formats multiple items correctly', () { + expect(Utils.itemCountFormat(0), equals('0 items')); + expect(Utils.itemCountFormat(2), equals('2 items')); + expect(Utils.itemCountFormat(100), equals('100 items')); + }); + + test('uses custom suffixes', () { + expect(Utils.itemCountFormat(1, singleSuffix: 'site', multiSuffix: 'sites'), equals('1 site')); + expect(Utils.itemCountFormat(5, singleSuffix: 'site', multiSuffix: 'sites'), equals('5 sites')); + }); + }); + + group('Utils Constants Tests', () { + test('minInteractiveSize has correct value', () { + expect(Utils.minInteractiveSize, equals(44.0)); + }); + }); +} diff --git a/test/test_helpers.dart b/test/test_helpers.dart new file mode 100644 index 00000000..190e94c1 --- /dev/null +++ b/test/test_helpers.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_nebula/models/certificate.dart'; +import 'package:mobile_nebula/models/site.dart'; +import 'package:mobile_nebula/models/static_hosts.dart'; +import 'package:mobile_nebula/models/unsafe_route.dart'; +import 'package:mobile_nebula/services/theme.dart'; + +/// Creates a MaterialApp wrapper for testing widgets +Widget createTestApp({required Widget child}) { + // Use a simple default text theme since we can't easily create one without a BuildContext + final textTheme = Typography.material2021().black; + MaterialTheme theme = MaterialTheme(textTheme); + + return MaterialApp( + theme: theme.light(), + darkTheme: theme.dark(), + home: Scaffold(body: child), + ); +} + +/// Creates a mock Site for testing +/// Note: This creates a Site instance but does not set up platform channels +/// Tests using this should mock platform calls or avoid triggering them +Site createMockSite({ + String? name, + String? id, + Map? staticHostmap, + List? ca, + CertificateInfo? certInfo, + int? lhDuration, + int? port, + String? cipher, + int? sortKey, + int? mtu, + bool? connected, + String? status, + String? logFile, + String? logVerbosity, + List? errors, + List? unsafeRoutes, + bool? managed, + String? rawConfig, + DateTime? lastManagedUpdate, +}) { + // Create a site with test-friendly defaults + return MockSite( + name: name ?? 'Test Site', + id: id ?? 'test-site-id', + staticHostmap: staticHostmap ?? {}, + ca: ca ?? [], + certInfo: certInfo, + lhDuration: lhDuration ?? 0, + port: port ?? 4242, + cipher: cipher ?? "aes", + sortKey: sortKey ?? 0, + mtu: mtu ?? 1300, + connected: connected ?? false, + status: status ?? 'Disconnected', + logFile: logFile ?? '', + logVerbosity: logVerbosity ?? 'info', + errors: errors ?? [], + unsafeRoutes: unsafeRoutes ?? [], + managed: managed ?? false, + rawConfig: rawConfig, + lastManagedUpdate: lastManagedUpdate, + ); +} + +/// A mock Site that doesn't try to set up EventChannel +class MockSite extends Site { + MockSite({ + required super.name, + required super.id, + required super.staticHostmap, + required super.ca, + required super.certInfo, + required super.lhDuration, + required super.port, + required super.cipher, + required super.sortKey, + required super.mtu, + required super.connected, + required super.status, + required super.logFile, + required super.logVerbosity, + required super.errors, + required super.unsafeRoutes, + required super.managed, + required super.rawConfig, + required super.lastManagedUpdate, + }) { + // Override the initialization to prevent EventChannel setup + _mockChangeController = StreamController.broadcast(); + } + + late StreamController _mockChangeController; + + @override + Stream onChange() => _mockChangeController.stream; + + @override + void dispose() { + _mockChangeController.close(); + super.dispose(); + } + + /// Simulate a site change event + void simulateChange() { + _mockChangeController.add(null); + } + + /// Simulate a site error event + void simulateError(String error) { + _mockChangeController.addError(error); + } +} + +/// Pump frames until there are no more scheduled frames +Future pumpUntilSettled(WidgetTester tester) async { + await tester.pumpAndSettle(); + // Give extra time for async operations + await tester.pump(const Duration(milliseconds: 100)); +}