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
58 changes: 58 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Test

on:
pull_request:
push:
branches:
- main

jobs:
test:
name: Run tests with coverage
runs-on: ubuntu-latest
env:
# Ratcheted up phase by phase as the testing plan progresses (target: 80%).
# test/coverage_helper_test.dart imports the whole app so every lib/ file
# is measured, not just the ones touched by tests.
COVERAGE_MIN: 14
steps:
- uses: actions/checkout@v5

- uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.3
channel: stable
cache: true

- name: Install dependencies
run: flutter pub get

- name: Generate code (riverpod codegen)
run: dart run build_runner build

- name: Run tests with coverage
run: flutter test --coverage

- name: Filter out generated files from coverage
run: |
sudo apt-get update -y && sudo apt-get install -y lcov
lcov --remove coverage/lcov.info \
'**/*.g.dart' \
-o coverage/lcov.info \
--ignore-errors unused,empty

- name: Report coverage summary
run: |
lcov --summary coverage/lcov.info --ignore-errors empty | tee coverage_summary.txt
{
echo "### Coverage summary"
echo '```'
cat coverage_summary.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

- name: Enforce minimum coverage
run: |
pct=$(grep -oP 'lines\.+: \K[0-9]+(\.[0-9]+)?' coverage_summary.txt)
echo "Line coverage: ${pct}% (minimum: ${COVERAGE_MIN}%)"
awk -v pct="$pct" -v min="$COVERAGE_MIN" 'BEGIN { exit !(pct >= min) }'
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ migrate_working_dir/
.pub/
/build/
/coverage/
/coverage_summary.txt

# Symbolication related
app.*.symbols
Expand Down
29 changes: 20 additions & 9 deletions lib/core/theme/app_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import 'app_colors.dart';

abstract final class AppTheme {
static ThemeData get light => _build(Brightness.light);
static ThemeData get dark => _build(Brightness.dark);

static ThemeData _build(Brightness brightness) {
// ColorScheme.fromSeed tones down a pure-red seed into a muddy brick tone
Expand All @@ -14,8 +13,7 @@ abstract final class AppTheme {
seedColor: AppColors.univalleRed,
brightness: brightness,
).copyWith(primary: AppColors.univalleRed);
final baseTextTheme =
brightness == Brightness.dark ? ThemeData.dark().textTheme : ThemeData.light().textTheme;
final baseTextTheme = ThemeData.light().textTheme;
final textTheme = GoogleFonts.poppinsTextTheme(baseTextTheme).apply(
bodyColor: colorScheme.onSurface,
displayColor: colorScheme.onSurface,
Expand All @@ -36,21 +34,31 @@ abstract final class AppTheme {
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(color: colorScheme.primary),
textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
inputDecorationTheme: InputDecorationTheme(
Expand All @@ -72,7 +80,10 @@ abstract final class AppTheme {
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.error, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ class LibraryRepositoryImpl implements LibraryRepository {
);
return Ok(account.toEntity());
} on AppException catch (e) {
print('LibraryRepositoryImpl.getAccount: $e');
return Err(mapExceptionToFailure(e));
}
}
Expand Down
8 changes: 8 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.8.1"
mocktail:
dependency: "direct dev"
description:
name: mocktail
sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
objective_c:
dependency: transitive
description:
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dev_dependencies:
riverpod_generator: ^4.0.9
build_runner: ^2.16.1
flutter_native_splash: ^2.4.8
mocktail: ^1.0.4

flutter:
uses-material-design: true
Expand Down
56 changes: 56 additions & 0 deletions test/core/error/exception_mapper_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:univalle_app/core/error/exception_mapper.dart';
import 'package:univalle_app/core/error/exceptions.dart';
import 'package:univalle_app/core/error/failures.dart';

void main() {
test(
'maps ServerException to ServerFailure, preserving message and status code',
() {
final failure = mapExceptionToFailure(
const ServerException(message: 'boom', statusCode: 500),
);
expect(failure, isA<ServerFailure>());
expect(failure.message, 'boom');
expect((failure as ServerFailure).statusCode, 500);
},
);

test('maps NetworkException to NetworkFailure', () {
final failure = mapExceptionToFailure(
const NetworkException(message: 'offline'),
);
expect(failure, isA<NetworkFailure>());
expect(failure.message, 'offline');
});

test('maps CacheException to CacheFailure', () {
final failure = mapExceptionToFailure(
const CacheException(message: 'no local data'),
);
expect(failure, isA<CacheFailure>());
expect(failure.message, 'no local data');
});

test('maps AuthException to AuthFailure', () {
final failure = mapExceptionToFailure(
const AuthException(message: 'bad credentials'),
);
expect(failure, isA<AuthFailure>());
expect(failure.message, 'bad credentials');
});

test('maps BusinessException to BusinessFailure, preserving retryable', () {
final failure = mapExceptionToFailure(
const BusinessException(message: 'no survey', retryable: false),
);
expect(failure, isA<BusinessFailure>());
expect((failure as BusinessFailure).retryable, isFalse);
});

test('maps any other error to UnknownFailure using its toString', () {
final failure = mapExceptionToFailure(StateError('unexpected'));
expect(failure, isA<UnknownFailure>());
expect(failure.message, StateError('unexpected').toString());
});
}
80 changes: 80 additions & 0 deletions test/core/error/failures_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:univalle_app/core/constants/app_strings.dart';
import 'package:univalle_app/core/error/failures.dart';

void main() {
group('ServerFailure', () {
test('maps 401 to the session-expired message', () {
final failure = ServerFailure(message: 'x', statusCode: 401);
expect(failure.userMessage, AppStrings.sessionExpired);
});

test('maps 403 to the forbidden message', () {
final failure = ServerFailure(message: 'x', statusCode: 403);
expect(failure.userMessage, AppStrings.forbidden);
});

test('maps 404 to the not-found message', () {
final failure = ServerFailure(message: 'x', statusCode: 404);
expect(failure.userMessage, AppStrings.notFound);
});

test('maps any 5xx to the server-error message', () {
expect(
ServerFailure(message: 'x', statusCode: 500).userMessage,
AppStrings.serverError,
);
expect(
ServerFailure(message: 'x', statusCode: 503).userMessage,
AppStrings.serverError,
);
});

test(
'maps an unrecognized status code to the generic request-error message',
() {
expect(
ServerFailure(message: 'x', statusCode: 418).userMessage,
AppStrings.requestError,
);
},
);

test('maps a null status code to the generic request-error message', () {
expect(
ServerFailure(message: 'x', statusCode: null).userMessage,
AppStrings.requestError,
);
});
});

test('NetworkFailure uses the network-error message', () {
expect(NetworkFailure(message: 'x').userMessage, AppStrings.networkError);
});

test('CacheFailure uses the cache-error message', () {
expect(CacheFailure(message: 'x').userMessage, AppStrings.cacheError);
});

test('UnknownFailure uses the generic-error message', () {
expect(UnknownFailure(message: 'x').userMessage, AppStrings.genericError);
});

test('AuthFailure surfaces its own message as the user message', () {
expect(
AuthFailure(message: 'Sesión inválida').userMessage,
'Sesión inválida',
);
});

test('BusinessFailure surfaces its own message as the user message and defaults to retryable', () {
final failure = BusinessFailure(message: 'Encuesta no disponible');
expect(failure.userMessage, 'Encuesta no disponible');
expect(failure.retryable, isTrue);
});

test('BusinessFailure can be marked as non-retryable', () {
final failure = BusinessFailure(message: 'x', retryable: false);
expect(failure.retryable, isFalse);
});
}
40 changes: 40 additions & 0 deletions test/core/error/result_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:univalle_app/core/error/failures.dart';
import 'package:univalle_app/core/error/result.dart';

void main() {
group('Ok', () {
test('isOk is true and isErr is false', () {
const result = Ok<int>(42);
expect(result.isOk, isTrue);
expect(result.isErr, isFalse);
});

test('fold calls onSuccess with the value', () {
const result = Ok<int>(42);
final folded = result.fold(
onError: (_) => 'error',
onSuccess: (value) => 'ok:$value',
);
expect(folded, 'ok:42');
});
});

group('Err', () {
test('isErr is true and isOk is false', () {
final result = Err<int>(UnknownFailure(message: 'x'));
expect(result.isErr, isTrue);
expect(result.isOk, isFalse);
});

test('fold calls onError with the failure', () {
final failure = UnknownFailure(message: 'x');
final result = Err<int>(failure);
final folded = result.fold(
onError: (f) => 'error:${f.message}',
onSuccess: (_) => 'ok',
);
expect(folded, 'error:x');
});
});
}
53 changes: 53 additions & 0 deletions test/core/extensions/snackbar_extension_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:univalle_app/core/extensions/snackbar_extension.dart';

void main() {
testWidgets('shows a SnackBar with the given message', (tester) async {
late BuildContext capturedContext;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
capturedContext = context;
return const SizedBox();
},
),
),
),
);

capturedContext.showSnack('Hola mundo');
await tester.pump();

expect(find.text('Hola mundo'), findsOneWidget);
});

testWidgets(
'hides the currently visible SnackBar before showing the new one',
(tester) async {
late BuildContext capturedContext;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
capturedContext = context;
return const SizedBox();
},
),
),
),
);

capturedContext.showSnack('Primero');
await tester.pump();
capturedContext.showSnack('Segundo');
await tester.pump();

expect(find.text('Primero'), findsNothing);
expect(find.text('Segundo'), findsOneWidget);
},
);
}
Loading