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
52 changes: 51 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,54 @@ jobs:
run: melos run build_runner:build
timeout-minutes: 5

- run: melos run test
- name: Run Unit Tests
run: melos run test

integration-test:
runs-on: ubuntu-latest
name: Integration Tests
steps:
- uses: actions/checkout@v2

- name: Install Linux Desktop Dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev ninja-build pkg-config

- name: Install FVM
shell: bash
run: |
curl -fsSL https://fvm.app/install.sh | bash
echo "/home/runner/fvm/bin" >> $GITHUB_PATH
export PATH="/home/runner/fvm/bin:$PATH"
fvm use stable --force

- uses: kuhnroyal/flutter-fvm-config-action@v2
id: fvm-config-action

- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ steps.fvm-config-action.outputs.FLUTTER_VERSION }}
channel: ${{ steps.fvm-config-action.outputs.FLUTTER_CHANNEL }}

- name: Setup Melos
uses: bluefireteam/melos-action@v3

- name: Install dependencies
run: flutter pub get

- name: Build Runner
run: melos run build_runner:build
timeout-minutes: 5

- name: Build SuperDeck Assets
run: |
cd demo
dart run superdeck_cli:main build
timeout-minutes: 5

- name: Run Integration Tests
uses: coactions/setup-xvfb@v1
with:
run: melos run test:integration
timeout-minutes: 10
Empty file added demo/.superdeck/assets/.gitkeep
Empty file.
229 changes: 229 additions & 0 deletions demo/integration_test/app_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import 'helpers/test_helpers.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

group('SuperDeck Integration Tests', () {
setUpAll(() async {
await TestApp.initialize();
});

group('App Startup', () {
testWidgets('app starts successfully without errors', (tester) async {
await tester.pumpWidget(const TestApp());

// Wait for initial load
await tester.pump();

// Verify no error screen is shown
expect(find.textContaining('Error loading presentation'), findsNothing);

// Wait for app to fully settle
await tester.pumpAndSettle(const Duration(seconds: 5));

// Check for exceptions, but ignore RenderFlex overflow which is common
// in CI environments with smaller viewport sizes
final exception = tester.takeException();
if (exception != null) {
final isLayoutOverflow = exception.toString().contains('overflowed');
expect(isLayoutOverflow, isTrue,
reason: 'Only layout overflow is acceptable, got: $exception');
}
});

testWidgets('app shows loading state before slides load', (tester) async {
await tester.pumpWidget(const TestApp());

// Immediately after pump, check initial state
await tester.pump();

// The app should be in either loading or loaded state
// (depending on timing, deck may load very quickly in tests)
final controller = findDeckController(tester);

// If controller found early, it may already be loading
if (controller != null) {
// Either loading or already loaded is acceptable
expect(
controller.isLoading.value || !controller.isLoading.value,
isTrue,
);
}

// Wait for full load
await tester.pumpAndSettle(const Duration(seconds: 5));
});
});

group('Slide Loading', () {
testWidgets('slides load and display', (tester) async {
final controller = await tester.pumpTestApp();

expect(controller, isNotNull, reason: 'DeckController should be available');
expect(controller!.isLoading.value, isFalse, reason: 'Loading should complete');
expect(controller.hasError.value, isFalse, reason: 'No error should occur');

final slideCount = controller.totalSlides.value;
expect(slideCount, greaterThan(0), reason: 'Should have at least one slide');
});

testWidgets('demo app has at least 5 slides', (tester) async {
final controller = await tester.pumpTestApp();

expect(controller, isNotNull);
expect(
controller!.totalSlides.value,
greaterThanOrEqualTo(5),
reason: 'Demo should have at least 5 slides',
);
});

testWidgets('first slide displays correctly', (tester) async {
final controller = await tester.pumpTestApp();

expect(controller, isNotNull);
expect(controller!.currentIndex.value, 0, reason: 'Should start at first slide');
expect(
controller.currentSlide.value,
isNotNull,
reason: 'Current slide should be available',
);
});
});

group('Navigation', () {
testWidgets('can navigate to next slide', (tester) async {
final controller = await tester.pumpTestApp();

expect(controller, isNotNull);
expect(controller!.currentIndex.value, 0);
expect(controller.canGoNext.value, isTrue, reason: 'Should be able to go next');

// Navigate to next slide
await controller.nextSlide();
await tester.pumpAndSettle(const Duration(seconds: 2));

expect(controller.currentIndex.value, 1, reason: 'Should be on second slide');
});

testWidgets('can navigate to previous slide', (tester) async {
final controller = await tester.pumpTestApp();

expect(controller, isNotNull);

// First navigate to slide 1
await tester.navigateToSlide(controller!, 1);
expect(controller.currentIndex.value, 1);
expect(controller.canGoPrevious.value, isTrue);

// Now navigate back
await controller.previousSlide();
await tester.pumpAndSettle(const Duration(seconds: 2));

expect(controller.currentIndex.value, 0, reason: 'Should be back on first slide');
});

testWidgets('canGoPrevious is false on first slide', (tester) async {
final controller = await tester.pumpTestApp();

expect(controller, isNotNull);
expect(controller!.currentIndex.value, 0);
expect(controller.canGoPrevious.value, isFalse);
});

testWidgets('canGoNext is false on last slide', (tester) async {
final controller = await tester.pumpTestApp();

expect(controller, isNotNull);

final lastIndex = controller!.totalSlides.value - 1;

// Navigate to last slide
await tester.navigateToSlide(controller, lastIndex);

expect(controller.currentIndex.value, lastIndex);
expect(controller.canGoNext.value, isFalse);
});

testWidgets('goToSlide navigates to specific slide', (tester) async {
final controller = await tester.pumpTestApp();

expect(controller, isNotNull);

// Navigate to slide 3
await tester.navigateToSlide(controller!, 3);

expect(controller.currentIndex.value, 3);
});

testWidgets('navigation updates currentSlide', (tester) async {
final controller = await tester.pumpTestApp();

expect(controller, isNotNull);

final slide0 = controller!.currentSlide.value;
expect(slide0, isNotNull);
expect(slide0!.slideIndex, 0);

// Navigate to slide 2
await tester.navigateToSlide(controller, 2);

final slide2 = controller.currentSlide.value;
expect(slide2, isNotNull);
expect(slide2!.slideIndex, 2);
});
});

group('UI State', () {
testWidgets('menu starts closed', (tester) async {
final controller = await tester.pumpTestApp();

expect(controller, isNotNull);
expect(controller!.isMenuOpen.value, isFalse);
});

testWidgets('menu can be toggled', (tester) async {
final controller = await tester.pumpTestApp();

expect(controller, isNotNull);
expect(controller!.isMenuOpen.value, isFalse);

controller.openMenu();
await tester.pumpAndSettle();
expect(controller.isMenuOpen.value, isTrue);

controller.closeMenu();
await tester.pumpAndSettle();
expect(controller.isMenuOpen.value, isFalse);
});

testWidgets('notes panel can be toggled', (tester) async {
final controller = await tester.pumpTestApp();

expect(controller, isNotNull);
expect(controller!.isNotesOpen.value, isFalse);

controller.toggleNotes();
await tester.pumpAndSettle();
expect(controller.isNotesOpen.value, isTrue);

controller.toggleNotes();
await tester.pumpAndSettle();
expect(controller.isNotesOpen.value, isFalse);
});
});

group('Error Handling', () {
testWidgets('app handles successful deck load', (tester) async {
final controller = await tester.pumpTestApp();

expect(controller, isNotNull);
expect(controller!.hasError.value, isFalse);
expect(controller.error.value, isNull);
});
});
});
}
85 changes: 85 additions & 0 deletions demo/integration_test/helpers/test_helpers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:signals_flutter/signals_flutter.dart';
import 'package:superdeck/superdeck.dart';

import 'package:superdeck_example/src/parts/background.dart';
import 'package:superdeck_example/src/parts/footer.dart';
import 'package:superdeck_example/src/parts/header.dart';
import 'package:superdeck_example/src/style.dart';
import 'package:superdeck_example/src/widgets/demo_widgets.dart';

/// Test app widget that mirrors the production app configuration.
class TestApp extends StatelessWidget {
const TestApp({super.key});

/// Initializes dependencies for testing.
///
/// Should be called in setUpAll() before any tests run.
static Future<void> initialize() async {
WidgetsFlutterBinding.ensureInitialized();
SignalsObserver.instance = null;
WidgetsBinding.instance.ensureSemantics();
await SuperDeckApp.initialize();
}

@override
Widget build(BuildContext context) {
return SuperDeckApp(
options: DeckOptions(
baseStyle: borderedStyle(),
widgets: demoWidgets,
styles: {
'announcement': announcementStyle(),
'quote': quoteStyle(),
},
parts: const SlideParts(
header: HeaderPart(),
footer: FooterPart(),
background: BackgroundPart(),
),
),
);
}
}

/// Finds the DeckController from the widget tree.
///
/// Returns null if the controller cannot be found.
DeckController? findDeckController(WidgetTester tester) {
try {
final scaffoldFinder = find.byType(Scaffold);
if (scaffoldFinder.evaluate().isEmpty) return null;

final element = tester.element(scaffoldFinder.first);
return DeckController.of(element);
} catch (e) {
return null;
}
}

/// Extension on WidgetTester for common integration test operations.
extension IntegrationTestExtensions on WidgetTester {
/// Pumps the test app and waits for it to fully load.
///
/// Returns the DeckController for further assertions.
Future<DeckController?> pumpTestApp() async {
await pumpWidget(const TestApp());
await pumpAndSettle(const Duration(seconds: 5));
return findDeckController(this);
}

/// Waits for the app to finish loading slides.
Future<void> waitForSlidesLoaded(DeckController controller) async {
while (controller.isLoading.value) {
await pump(const Duration(milliseconds: 100));
}
await pumpAndSettle();
}

/// Navigates to a specific slide and waits for transition to complete.
Future<void> navigateToSlide(DeckController controller, int index) async {
await controller.goToSlide(index);
await pumpAndSettle(const Duration(seconds: 2));
}
}
2 changes: 2 additions & 0 deletions demo/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
flutter_lints: ^5.0.0
superdeck_cli: ^0.0.1
flutter:
Expand Down
10 changes: 10 additions & 0 deletions melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ scripts:
packageFilters:
dirExists: test

test:integration:
run: melos exec -- flutter test integration_test -d linux --fail-fast
description: Run flutter integration tests
packageFilters:
dirExists: integration_test

test:all:
run: melos run test && melos run test:integration
description: Run all tests (unit + integration)

test:coverage:
run: melos exec -- flutter test --coverage
description: Run flutter test with coverage
Expand Down
Loading
Loading