diff --git a/assets/svg/icons/newspaper.svg b/assets/svg/icons/newspaper.svg new file mode 100644 index 0000000..351caa0 --- /dev/null +++ b/assets/svg/icons/newspaper.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/lib/core/constants/asset_paths.dart b/lib/core/constants/asset_paths.dart index 84f8e1a..241dd9d 100644 --- a/lib/core/constants/asset_paths.dart +++ b/lib/core/constants/asset_paths.dart @@ -20,4 +20,5 @@ abstract final class AssetPaths { static const iconFine = 'assets/svg/icons/circle-dollar-sign.svg'; static const iconHistory = 'assets/svg/icons/history.svg'; static const iconGithub = 'assets/svg/icons/github.svg'; + static const iconNewspaper = 'assets/svg/icons/newspaper.svg'; } diff --git a/lib/core/constants/news_constants.dart b/lib/core/constants/news_constants.dart new file mode 100644 index 0000000..021c6ed --- /dev/null +++ b/lib/core/constants/news_constants.dart @@ -0,0 +1,15 @@ +abstract final class NewsConstants { + static const String baseUrl = 'https://www.univalle.edu.co'; + static const String listPath = '/agencia-de-noticias'; + + // K2 (the Joomla component behind the news agency) paginates via a + // "start" query param rather than a page number, and always returns this + // many items per page. + static const int itemsPerPage = 21; + + static const String itemSelector = '.catItemView'; + static const String titleLinkSelector = '.agencia-lst-item-titulo a'; + static const String summarySelector = '.agencia-lista-texto'; + static const String imageSelector = '.agencia-lst-img img'; + static const String categorySelector = '.agencia-lista-categoria-item'; +} diff --git a/lib/core/network/news_dio_provider.dart b/lib/core/network/news_dio_provider.dart new file mode 100644 index 0000000..8c14cd3 --- /dev/null +++ b/lib/core/network/news_dio_provider.dart @@ -0,0 +1,20 @@ +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../constants/news_constants.dart'; + +part 'news_dio_provider.g.dart'; + +// The news agency page is public (no login), so unlike the other scraped +// sources this needs neither a cookie jar nor a persistent session. +@Riverpod(keepAlive: true) +Dio newsDio(Ref ref) { + return Dio( + BaseOptions( + baseUrl: NewsConstants.baseUrl, + responseType: ResponseType.plain, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 30), + ), + ); +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index e7a1c65..315bc9c 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -7,6 +7,7 @@ import '../../features/digital_card/presentation/views/digital_card_view.dart'; import '../../features/home/presentation/views/all_shortcuts_view.dart'; import '../../features/home/presentation/views/home_view.dart'; import '../../features/library/presentation/views/library_view.dart'; +import '../../features/news/presentation/views/news_view.dart'; import '../../features/profile/presentation/views/profile_view.dart'; import '../../features/resolution/presentation/views/resolution_view.dart'; import '../../features/restaurant/presentation/views/pending_payment_view.dart'; @@ -83,6 +84,10 @@ GoRouter appRouter(Ref ref) { path: AppRoutes.library, builder: (context, state) => const LibraryView(), ), + GoRoute( + path: AppRoutes.news, + builder: (context, state) => const NewsView(), + ), GoRoute( path: AppRoutes.allFunctionalities, builder: (context, state) => const AllShortcutsView(), diff --git a/lib/core/router/app_routes.dart b/lib/core/router/app_routes.dart index 31f3685..14475e7 100644 --- a/lib/core/router/app_routes.dart +++ b/lib/core/router/app_routes.dart @@ -12,6 +12,7 @@ abstract final class AppRoutes { static const schedule = '/schedule'; static const restaurant = '/restaurant'; static const library = '/library'; + static const news = '/news'; static const pendingPayment = '/restaurant/pending-payment'; static const allFunctionalities = '/functionalities'; } diff --git a/lib/features/home/home_strings.dart b/lib/features/home/home_strings.dart index 82dde68..ce0c255 100644 --- a/lib/features/home/home_strings.dart +++ b/lib/features/home/home_strings.dart @@ -18,6 +18,7 @@ abstract final class HomeStrings { static const scheduleShortcut = 'Horario de clases'; static const libraryShortcut = 'Biblioteca'; static const restaurantShortcut = 'Central (Meléndez)'; + static const newsShortcut = 'Noticias'; static const comingSoon = 'Muy pronto podrás usar esta función.'; static const allFunctionalitiesTitle = 'Todas las funcionalidades'; static const repositoryLabel = 'code3743/univalle_app'; diff --git a/lib/features/home/presentation/widgets/quick_access_items.dart b/lib/features/home/presentation/widgets/quick_access_items.dart index 2021be6..d132dca 100644 --- a/lib/features/home/presentation/widgets/quick_access_items.dart +++ b/lib/features/home/presentation/widgets/quick_access_items.dart @@ -70,5 +70,11 @@ List quickAccessItems(BuildContext context) { accent: AppColors.univalleRed, onTap: () => context.push(AppRoutes.restaurant), ), + QuickAccessItem( + iconAsset: AssetPaths.iconNewspaper, + label: HomeStrings.newsShortcut, + accent: AppColors.accentBlue, + onTap: () => context.push(AppRoutes.news), + ), ]; } diff --git a/lib/features/news/data/datasources/news_remote_datasource.dart b/lib/features/news/data/datasources/news_remote_datasource.dart new file mode 100644 index 0000000..7a45ba0 --- /dev/null +++ b/lib/features/news/data/datasources/news_remote_datasource.dart @@ -0,0 +1,66 @@ +import 'package:dio/dio.dart'; +import 'package:html/dom.dart'; +import 'package:html/parser.dart' show parse; + +import '../../../../core/constants/news_constants.dart'; +import '../../../../core/network/dio_exception_mapper.dart'; +import '../models/news_article_model.dart'; + +class NewsRemoteDataSource { + final Dio _dio; + const NewsRemoteDataSource(this._dio); + + Future> fetchNews({required int page}) async { + final response = await _run( + () => _dio.get( + NewsConstants.listPath, + queryParameters: page == 0 + ? null + : {'start': page * NewsConstants.itemsPerPage}, + ), + ); + + final document = parse(response.data as String); + return document + .querySelectorAll(NewsConstants.itemSelector) + .map(_parseArticle) + .whereType() + .toList(); + } + + NewsArticleModel? _parseArticle(Element item) { + final titleAnchor = item.querySelector(NewsConstants.titleLinkSelector); + final title = titleAnchor?.text.trim(); + final href = titleAnchor?.attributes['href']; + if (title == null || title.isEmpty || href == null) return null; + + final imageSrc = item + .querySelector(NewsConstants.imageSelector) + ?.attributes['src']; + final category = item + .querySelector(NewsConstants.categorySelector) + ?.text + .trim(); + + return NewsArticleModel( + title: title, + summary: + item.querySelector(NewsConstants.summarySelector)?.text.trim() ?? '', + sourceUrl: _toAbsoluteUrl(href), + imageUrl: imageSrc == null ? null : _toAbsoluteUrl(imageSrc), + category: category == null || category.isEmpty ? null : category, + ); + } + + String _toAbsoluteUrl(String path) { + return Uri.parse(NewsConstants.baseUrl).resolve(path).toString(); + } + + Future _run(Future Function() request) async { + try { + return await request(); + } on DioException catch (e) { + throw mapDioException(e); + } + } +} diff --git a/lib/features/news/data/models/news_article_model.dart b/lib/features/news/data/models/news_article_model.dart new file mode 100644 index 0000000..2d2c819 --- /dev/null +++ b/lib/features/news/data/models/news_article_model.dart @@ -0,0 +1,25 @@ +import '../../domain/entities/news_article.dart'; + +class NewsArticleModel { + final String title; + final String summary; + final String sourceUrl; + final String? imageUrl; + final String? category; + + const NewsArticleModel({ + required this.title, + required this.summary, + required this.sourceUrl, + this.imageUrl, + this.category, + }); + + NewsArticle toEntity() => NewsArticle( + title: title, + summary: summary, + sourceUrl: sourceUrl, + imageUrl: imageUrl, + category: category, + ); +} diff --git a/lib/features/news/data/repositories/news_repository_impl.dart b/lib/features/news/data/repositories/news_repository_impl.dart new file mode 100644 index 0000000..7b206f7 --- /dev/null +++ b/lib/features/news/data/repositories/news_repository_impl.dart @@ -0,0 +1,21 @@ +import '../../../../core/error/exception_mapper.dart'; +import '../../../../core/error/exceptions.dart'; +import '../../../../core/error/result.dart'; +import '../../domain/entities/news_article.dart'; +import '../../domain/repositories/news_repository.dart'; +import '../datasources/news_remote_datasource.dart'; + +class NewsRepositoryImpl implements NewsRepository { + final NewsRemoteDataSource _remote; + const NewsRepositoryImpl(this._remote); + + @override + Future>> getNews({required int page}) async { + try { + final articles = await _remote.fetchNews(page: page); + return Ok(articles.map((article) => article.toEntity()).toList()); + } on AppException catch (e) { + return Err(mapExceptionToFailure(e)); + } + } +} diff --git a/lib/features/news/domain/entities/news_article.dart b/lib/features/news/domain/entities/news_article.dart new file mode 100644 index 0000000..c0e8dbb --- /dev/null +++ b/lib/features/news/domain/entities/news_article.dart @@ -0,0 +1,15 @@ +class NewsArticle { + final String title; + final String summary; + final String sourceUrl; + final String? imageUrl; + final String? category; + + const NewsArticle({ + required this.title, + required this.summary, + required this.sourceUrl, + this.imageUrl, + this.category, + }); +} diff --git a/lib/features/news/domain/repositories/news_repository.dart b/lib/features/news/domain/repositories/news_repository.dart new file mode 100644 index 0000000..bb96cf2 --- /dev/null +++ b/lib/features/news/domain/repositories/news_repository.dart @@ -0,0 +1,6 @@ +import '../../../../core/error/result.dart'; +import '../entities/news_article.dart'; + +abstract interface class NewsRepository { + Future>> getNews({required int page}); +} diff --git a/lib/features/news/domain/usecases/get_news_usecase.dart b/lib/features/news/domain/usecases/get_news_usecase.dart new file mode 100644 index 0000000..9288da8 --- /dev/null +++ b/lib/features/news/domain/usecases/get_news_usecase.dart @@ -0,0 +1,12 @@ +import '../../../../core/error/result.dart'; +import '../entities/news_article.dart'; +import '../repositories/news_repository.dart'; + +class GetNewsUseCase { + final NewsRepository _repository; + const GetNewsUseCase(this._repository); + + Future>> call({required int page}) { + return _repository.getNews(page: page); + } +} diff --git a/lib/features/news/news_strings.dart b/lib/features/news/news_strings.dart new file mode 100644 index 0000000..1eeb653 --- /dev/null +++ b/lib/features/news/news_strings.dart @@ -0,0 +1,7 @@ +abstract final class NewsStrings { + static const title = 'Noticias'; + static const emptyMessage = 'No encontramos noticias por ahora.'; + static const cannotOpenArticleLink = 'No pudimos abrir la noticia.'; + static const loadMoreError = + 'No pudimos cargar más noticias, sigue desplazándote para intentar de nuevo.'; +} diff --git a/lib/features/news/presentation/providers/news_providers.dart b/lib/features/news/presentation/providers/news_providers.dart new file mode 100644 index 0000000..2bf46a1 --- /dev/null +++ b/lib/features/news/presentation/providers/news_providers.dart @@ -0,0 +1,24 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../../core/network/news_dio_provider.dart'; +import '../../data/datasources/news_remote_datasource.dart'; +import '../../data/repositories/news_repository_impl.dart'; +import '../../domain/repositories/news_repository.dart'; +import '../../domain/usecases/get_news_usecase.dart'; + +part 'news_providers.g.dart'; + +@Riverpod(keepAlive: true) +NewsRemoteDataSource newsRemoteDataSource(Ref ref) { + return NewsRemoteDataSource(ref.watch(newsDioProvider)); +} + +@Riverpod(keepAlive: true) +NewsRepository newsRepository(Ref ref) { + return NewsRepositoryImpl(ref.watch(newsRemoteDataSourceProvider)); +} + +@Riverpod(keepAlive: true) +GetNewsUseCase getNewsUseCase(Ref ref) { + return GetNewsUseCase(ref.watch(newsRepositoryProvider)); +} diff --git a/lib/features/news/presentation/viewmodels/news_view_model.dart b/lib/features/news/presentation/viewmodels/news_view_model.dart new file mode 100644 index 0000000..d6e61a0 --- /dev/null +++ b/lib/features/news/presentation/viewmodels/news_view_model.dart @@ -0,0 +1,56 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../domain/entities/news_article.dart'; +import '../providers/news_providers.dart'; + +part 'news_view_model.g.dart'; + +class NewsFeed { + final List articles; + final bool hasMore; + const NewsFeed({required this.articles, required this.hasMore}); +} + +@riverpod +class NewsViewModel extends _$NewsViewModel { + int _page = 0; + bool _isLoadingMore = false; + + @override + Future build() async { + _page = 0; + final result = await ref.read(getNewsUseCaseProvider).call(page: _page); + return result.fold( + onError: (failure) => throw failure, + onSuccess: (articles) => + NewsFeed(articles: articles, hasMore: articles.isNotEmpty), + ); + } + + Future loadMore() async { + final feed = state.value; + if (feed == null || !feed.hasMore || _isLoadingMore) return; + + _isLoadingMore = true; + try { + final nextPage = _page + 1; + final result = await ref + .read(getNewsUseCaseProvider) + .call(page: nextPage); + result.fold( + onError: (failure) => throw failure, + onSuccess: (articles) { + _page = nextPage; + state = AsyncData( + NewsFeed( + articles: [...feed.articles, ...articles], + hasMore: articles.isNotEmpty, + ), + ); + }, + ); + } finally { + _isLoadingMore = false; + } + } +} diff --git a/lib/features/news/presentation/views/news_view.dart b/lib/features/news/presentation/views/news_view.dart new file mode 100644 index 0000000..c27d4d4 --- /dev/null +++ b/lib/features/news/presentation/views/news_view.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/error/failures.dart'; +import '../../../../core/extensions/snackbar_extension.dart'; +import '../../../../core/theme/app_spacing.dart'; +import '../../../../core/widgets/app_loading_indicator.dart'; +import '../../../../core/widgets/app_scaffold.dart'; +import '../../../../core/widgets/async_value_widget.dart'; +import '../../news_strings.dart'; +import '../viewmodels/news_view_model.dart'; +import '../widgets/news_article_card.dart'; + +class NewsView extends ConsumerStatefulWidget { + const NewsView({super.key}); + + @override + ConsumerState createState() => _NewsViewState(); +} + +class _NewsViewState extends ConsumerState { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + final position = _scrollController.position; + if (position.pixels < position.maxScrollExtent - 300) return; + _loadMore(); + } + + Future _loadMore() async { + try { + await ref.read(newsViewModelProvider.notifier).loadMore(); + } on Failure catch (failure) { + if (mounted) context.showSnack(failure.userMessage); + } + } + + @override + Widget build(BuildContext context) { + final newsState = ref.watch(newsViewModelProvider); + + return AppScaffold( + title: NewsStrings.title, + scrollable: false, + body: AsyncValueWidget( + value: newsState, + onRetry: () => ref.invalidate(newsViewModelProvider), + data: (feed) => feed.articles.isEmpty + ? Center( + child: Text( + NewsStrings.emptyMessage, + style: Theme.of(context).textTheme.bodyMedium, + ), + ) + : ListView.separated( + controller: _scrollController, + itemCount: feed.articles.length + (feed.hasMore ? 1 : 0), + separatorBuilder: (context, index) => + const SizedBox(height: AppSpacing.sm), + itemBuilder: (context, index) { + if (index >= feed.articles.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.md), + child: Center(child: AppLoadingIndicator()), + ); + } + return NewsArticleCard(article: feed.articles[index]); + }, + ), + ), + ); + } +} diff --git a/lib/features/news/presentation/widgets/news_article_card.dart b/lib/features/news/presentation/widgets/news_article_card.dart new file mode 100644 index 0000000..8f73d14 --- /dev/null +++ b/lib/features/news/presentation/widgets/news_article_card.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../core/extensions/snackbar_extension.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_spacing.dart'; +import '../../domain/entities/news_article.dart'; +import '../../news_strings.dart'; + +class NewsArticleCard extends StatelessWidget { + const NewsArticleCard({super.key, required this.article}); + + final NewsArticle article; + + Future _openArticle(BuildContext context) async { + final uri = Uri.tryParse(article.sourceUrl); + final launched = + uri != null && + await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!launched && context.mounted) { + context.showSnack(NewsStrings.cannotOpenArticleLink); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Material( + color: AppColors.white, + shadowColor: AppColors.accentBlue, + borderRadius: BorderRadius.circular(20), + elevation: .1, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => _openArticle(context), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (article.imageUrl != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Image.network( + article.imageUrl!, + width: 84, + height: 84, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _ThumbnailPlaceholder(), + ), + ), + const SizedBox(width: AppSpacing.sm + 4), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (article.category != null) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + article.category!, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: AppColors.accentBlue, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + article.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + article.summary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ThumbnailPlaceholder extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + width: 84, + height: 84, + decoration: BoxDecoration( + color: AppColors.accentBlue.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.image_not_supported_outlined, + color: AppColors.accentBlue, + ), + ); + } +} diff --git a/lib/features/schedule/data/datasources/sira_schedule_remote_datasource.dart b/lib/features/schedule/data/datasources/sira_schedule_remote_datasource.dart index 2801d50..99823ae 100644 --- a/lib/features/schedule/data/datasources/sira_schedule_remote_datasource.dart +++ b/lib/features/schedule/data/datasources/sira_schedule_remote_datasource.dart @@ -63,8 +63,15 @@ class SiraScheduleRemoteDataSource { } final document = parse(latin1.decode(response.data as List)); - return document - .querySelectorAll('table[width="768"]>tbody>tr') + // SIRA occasionally renders the programming table twice for the same + // query (an identical `table[width="768"]` duplicated in the response). + // Scoping to the first one avoids parsing — and thus doubling — every + // session in it. + final table = document.querySelector('table[width="768"]'); + if (table == null) return const []; + + return table + .querySelectorAll('tbody>tr') .expand((row) => _parseGroupRow(row, subject)) .toList(); } diff --git a/test/features/news/data/datasources/news_remote_datasource_test.dart b/test/features/news/data/datasources/news_remote_datasource_test.dart new file mode 100644 index 0000000..c7f56ac --- /dev/null +++ b/test/features/news/data/datasources/news_remote_datasource_test.dart @@ -0,0 +1,130 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/features/news/data/datasources/news_remote_datasource.dart'; + +class MockDio extends Mock implements Dio {} + +Response _stringResponse(String html) => Response( + requestOptions: RequestOptions(path: ''), + statusCode: 200, + data: html, +); + +void main() { + late MockDio dio; + late NewsRemoteDataSource dataSource; + late String page1Html; + late String page2Html; + + setUpAll(() { + registerFallbackValue({}); + page1Html = File('test/fixtures/news/agencia_noticias_page1.html') + .readAsStringSync(); + page2Html = File('test/fixtures/news/agencia_noticias_page2.html') + .readAsStringSync(); + }); + + setUp(() { + dio = MockDio(); + dataSource = NewsRemoteDataSource(dio); + }); + + group('fetchNews', () { + test('parses title, summary, image, category and absolute urls from ' + 'the leading item on a real listing page', () async { + when(() => dio.get(any(), queryParameters: any(named: 'queryParameters'))) + .thenAnswer((_) async => _stringResponse(page1Html)); + + final articles = await dataSource.fetchNews(page: 0); + + expect(articles, isNotEmpty); + final first = articles.first; + expect( + first.title, + 'Univalle Radio entre los medios más consultados por líderes de ' + 'opinión en 2026', + ); + expect( + first.sourceUrl, + 'https://www.univalle.edu.co/arte-y-cultura/univalle-radio-los-medios-mas-consultados-por-lideres-de-opinion-en-2026', + ); + expect( + first.imageUrl, + 'https://www.univalle.edu.co/media/k2/items/cache/5ed6ec283ae02d0df1756eeb95f9359a_M.jpg', + ); + expect(first.category, 'Arte y Cultura'); + // The source HTML has a non-breaking space (" ") between + // "Radio" and "se" here, not a regular one. + expect( + first.summary, + contains('La emisora de Univalle Radio se destaca'), + ); + }); + + test('also parses secondary items, whose image lives in a nested ' + '"bloque-imagen" wrapper', () async { + when(() => dio.get(any(), queryParameters: any(named: 'queryParameters'))) + .thenAnswer((_) async => _stringResponse(page1Html)); + + final articles = await dataSource.fetchNews(page: 0); + + final secondary = articles.firstWhere( + (article) => + article.title == + 'El estudio patológico de las ' + 'infraestructuras', + ); + expect( + secondary.imageUrl, + 'https://www.univalle.edu.co/media/k2/items/cache/cbbfe09f6476751d6c431c2e323880fe_S.jpg', + ); + }); + + test('requests the "start" offset for pages after the first', () async { + when(() => dio.get(any(), queryParameters: any(named: 'queryParameters'))) + .thenAnswer((_) async => _stringResponse(page2Html)); + + await dataSource.fetchNews(page: 1); + + verify(() => dio.get(any(), queryParameters: {'start': 21})).called(1); + }); + + test('omits query parameters for the first page', () async { + when(() => dio.get(any(), queryParameters: any(named: 'queryParameters'))) + .thenAnswer((_) async => _stringResponse(page1Html)); + + await dataSource.fetchNews(page: 0); + + verify(() => dio.get(any(), queryParameters: null)).called(1); + }); + + test('maps a DioException to an AppException', () async { + when(() => dio.get(any(), queryParameters: any(named: 'queryParameters'))) + .thenThrow( + DioException( + requestOptions: RequestOptions(path: ''), + type: DioExceptionType.connectionTimeout, + ), + ); + + expect( + () => dataSource.fetchNews(page: 0), + throwsA(isA()), + ); + }); + + test('returns an empty list past the last page', () async { + when( + () => dio.get(any(), queryParameters: any(named: 'queryParameters')), + ).thenAnswer((_) async => _stringResponse('')); + + final articles = await dataSource.fetchNews(page: 500); + + expect(articles, isEmpty); + }); + }); +} diff --git a/test/features/news/data/models/news_article_model_test.dart b/test/features/news/data/models/news_article_model_test.dart new file mode 100644 index 0000000..4c038ed --- /dev/null +++ b/test/features/news/data/models/news_article_model_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/news/data/models/news_article_model.dart'; + +void main() { + test('toEntity maps every field', () { + const model = NewsArticleModel( + title: 'Título', + summary: 'Resumen', + sourceUrl: 'https://www.univalle.edu.co/noticia', + imageUrl: 'https://www.univalle.edu.co/imagen.jpg', + category: 'Categoría', + ); + + final entity = model.toEntity(); + + expect(entity.title, 'Título'); + expect(entity.summary, 'Resumen'); + expect(entity.sourceUrl, 'https://www.univalle.edu.co/noticia'); + expect(entity.imageUrl, 'https://www.univalle.edu.co/imagen.jpg'); + expect(entity.category, 'Categoría'); + }); + + test('toEntity keeps optional fields null when absent', () { + const model = NewsArticleModel( + title: 'Título', + summary: 'Resumen', + sourceUrl: 'https://www.univalle.edu.co/noticia', + ); + + final entity = model.toEntity(); + + expect(entity.imageUrl, isNull); + expect(entity.category, isNull); + }); +} diff --git a/test/features/news/data/repositories/news_repository_impl_test.dart b/test/features/news/data/repositories/news_repository_impl_test.dart new file mode 100644 index 0000000..bc48958 --- /dev/null +++ b/test/features/news/data/repositories/news_repository_impl_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/news/data/datasources/news_remote_datasource.dart'; +import 'package:univalle_app/features/news/data/models/news_article_model.dart'; +import 'package:univalle_app/features/news/data/repositories/news_repository_impl.dart'; +import 'package:univalle_app/features/news/domain/entities/news_article.dart'; + +class MockNewsRemoteDataSource extends Mock implements NewsRemoteDataSource {} + +void main() { + late MockNewsRemoteDataSource remote; + late NewsRepositoryImpl repository; + + setUp(() { + remote = MockNewsRemoteDataSource(); + repository = NewsRepositoryImpl(remote); + }); + + test('returns the articles mapped to entities on success', () async { + when(() => remote.fetchNews(page: 0)).thenAnswer( + (_) async => const [ + NewsArticleModel( + title: 'Título', + summary: 'Resumen', + sourceUrl: 'https://www.univalle.edu.co/noticia', + ), + ], + ); + + final result = await repository.getNews(page: 0); + + expect(result, isA>>()); + expect((result as Ok>).value.single.title, 'Título'); + }); + + test('maps a thrown AppException to a Failure', () async { + when(() => remote.fetchNews(page: 0)) + .thenThrow(const ServerException(message: 'boom', statusCode: 500)); + + final result = await repository.getNews(page: 0); + + expect((result as Err>).failure, isA()); + }); +} diff --git a/test/features/schedule/data/datasources/sira_schedule_remote_datasource_test.dart b/test/features/schedule/data/datasources/sira_schedule_remote_datasource_test.dart index f18c966..80a83d8 100644 --- a/test/features/schedule/data/datasources/sira_schedule_remote_datasource_test.dart +++ b/test/features/schedule/data/datasources/sira_schedule_remote_datasource_test.dart @@ -216,5 +216,38 @@ void main() { expect(session.room, ''); expect(session.campus, ''); }); + + // SIRA sometimes renders the same programming table twice in one + // response; only the first should be parsed or every session doubles. + test( + 'does not double sessions when SIRA repeats the table in one response', + () async { + const subject = ScheduleSubject( + code: '204025C', + group: '50', + campusId: '06', + name: 'INGLÉS CON FINES GENERALES Y ACADÉM. I', + ); + const groupRow = ''' + + + + + + + +
1PERIODO508 LUN: 18:00-22:00 ,SIN ESPACIO -- MG
DOCENTE PRUEBA UNO docente.uno@correounivalle.edu.coPrograma
+'''; + when(() => dio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _htmlResponse('$groupRow\n$groupRow')); + + final classes = await dataSource.fetchSchedule(subjects: [subject]); + + expect(classes, hasLength(1)); + expect(classes.single.day, Weekday.monday); + expect(classes.single.startTime, '18:00'); + expect(classes.single.endTime, '22:00'); + }, + ); }); } diff --git a/test/fixtures/news/agencia_noticias_page1.html b/test/fixtures/news/agencia_noticias_page1.html new file mode 100644 index 0000000..3b386b4 --- /dev/null +++ b/test/fixtures/news/agencia_noticias_page1.html @@ -0,0 +1,2480 @@ + + + + + + + + + + + + + + + + + Agencia de noticias - Universidad del Valle / Cali, Colombia + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + +
+
+
+
+
+ +
+
+ + + gov.co + +
+
+ + +
+
+ +
+ + + +
+
+
+ +
+
+
+
+
+ +
+
+ + + + +
+ + + + + + +
+ +
+
+
+ + + + + + + + + + + + + + + + +
+
+
+
+ + + +
+ + +
+
+ + + +
+
+
+ + +
+ + + + + + + +
+ + + +
+ + +
+ + +
+ + + + + + + + + + + + +
+ + + + + + + + +
+ La emisora de Univalle Radio se destaca en el estudio Panel de Opinión en su décima edición, en la categoría de medios y columnistas regionales por el departamento del Valle del Cauca. El Panel de Opinión es una medición, de…
+ +
+ +
+ + + Univalle Radio entre los medios más consultados por líderes de opinión en 2026 + + +
+
+ + + + + + +
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+
+ + + +
+
Más noticias destacadas
+ + +
+ + +
+ + + + + + +
+ + + El estudio patológico de las infraestructuras + + +
+
+ +
+
+ + +
+ Arte y Cultura
+ + +

+ + + El estudio patológico de las infraestructuras + +

+ +
+ + + + + + +
+ + + + + + + +
+ Las edificaciones son, hoy en día, el lugar donde mayor número de horas se permanece, independiente del…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Trabajamos en la recuperación de infraestructura + + +
+
+ +
+
+ + +
+ Lo que pasa en la U
+ + +

+ + + Trabajamos en la recuperación de infraestructura + +

+ +
+ + + + + + +
+ + + + + + + +
+ La Dirección de Infraestructura de Univalle está adelantando un proceso de evaluación y recuperación de la infraestructura…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Univalle Contigo: Muchos corazones, una sola comunidad + + +
+
+ +
+
+ + +
+ Lo que pasa en la U
+ + +

+ + + Univalle Contigo: Muchos corazones, una sola comunidad + +

+ +
+ + + + + + +
+ + + + + + + +
+ Desde el pasado lunes 10 de agosto, día en que un terremoto sacudió a Cali y a…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + IA y energías renovables: las claves del bebedero para aves que crearon estudiantes de Univalle + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ Un grupo de estudiantes de la Universidad del Valle diseñaron un bebedero inteligente para aves con un…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + A un mes del sismo que transformó nuestras vidas + + +
+
+ +
+
+ + +
+ Lo que pasa en la U
+ + +

+ + + A un mes del sismo que transformó nuestras vidas + +

+ +
+ + + + + + +
+ + + + + + + +
+ El pasado 10 de agosto, hace un mes, las entrañas de la tierra rugieron. Noventa segundos eternos,…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + ¡Silencio total! La ciudad que escucha bajo los escombros + + +
+
+ +
+
+ + +
+ Talento Vallecaucano
+ + +

+ + + ¡Silencio total! La ciudad que escucha bajo los escombros + +

+ +
+ + + + + + +
+ + + + + + + +
+ Rescatistas y voluntarios de todos los rincones trabajan sin parar en busca de sobrevivientes. Estos son los…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Atención de la catástrofe desde la Universidad + + +
+
+ +
+
+ + +
+ Talento Vallecaucano
+ + +

+ + + Atención de la catástrofe desde la Universidad + +

+ +
+ + + + + + +
+ + + + + + + +
+ Desde el mismo día en que el terremoto sacudió al país con graves consecuencias para la zona…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Cali (Pereira, Quibdó, El Aguila... ) está triste + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ Recorrer las calles de Cali después del 10 de agosto es una experiencia nueva cada día, cada…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Las reacciones y emociones que deja una emergencia natural + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ Reacciones como el miedo, angustia, tristeza, irritabilidad, preocupación o incluso momentos de bloqueo son normales luego de…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+
+
+ + +
+ + + +
+
Inicio
+
+
Página 1 de 446
+ +
+ + + +
+ +
+ + + + +
+

Nuestros Medios

+ + +
+
    +
  • Boton de Univalle TV Canal Universitario
  • +
  • Boton de Univalle Radio
  • +
  • Boton de la Revista Campus Revista Institucional
  • +
  • Boton de la Agencia de noticias Univalle
  • +
+ + +
+

Nuestros Eventos

+ + + + + +
+

Información de la Agencia

+
    +
  • Directora
  • +
  • Fulvia Carvajal
  • +
+
    +
  • Coordinador de Información
  • +
  • Diego Alejandro Guerrero
  • +
+
    +
  • Comunicadores y Periodistas
  • +
  • Laura María Parra
  • +
  • Yizeth Bonilla Vélez
  • +
  • Diana Patricia Sevilla
  • +
  • Edgar Hernán Cruz García
  • +
  • Melissa Pantoja Osorio
  • +
+
    +
  • Contactos
  • +
  • agenda@correounivalle.edu.co
  • +
  • Universidad del Valle
  • +
  • Cali, Colombia
  • +
+ + +
+ + + + + + + +
+
+
+
+
+ + + + + + + + +
+ +
+ + + + + \ No newline at end of file diff --git a/test/fixtures/news/agencia_noticias_page2.html b/test/fixtures/news/agencia_noticias_page2.html new file mode 100644 index 0000000..7b4656c --- /dev/null +++ b/test/fixtures/news/agencia_noticias_page2.html @@ -0,0 +1,3415 @@ + + + + + + + + + + + + + + + + + Agencia de noticias - Universidad del Valle / Cali, Colombia + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + +
+
+
+
+
+ +
+
+ + + gov.co + +
+
+ + +
+
+ +
+ + + +
+
+
+ +
+
+
+
+
+ +
+
+ + + + +
+ + + + + + +
+ +
+
+
+ + + + + + + + + + + + + + + + +
+
+
+
+ + + +
+ + +
+
+ + + +
+
+
+ + +
+ + + + + + + +
+ + + +
+ + +
+ + +
+ + + + + +
+ + +
+ Lo que pasa en la U
+ + +

+ + + Las responsabilidades + +

+ +
+ + + + + + +
+ + + + + + + + +
+ Por: Alberto Valencia GutiérrezProfesor Facultad de Ciencias Sociales y Económicas, columnista de El Espectador.El proyecto de retirar a Colombia de la Corte Penal Internacional (a pesar de las críticas que se puedan hacer a esta institución) y los esfuerzos por…
+ +
+ +
+ + + Las responsabilidades + + +
+
+ + + + + + +
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+
+ + + +
+
Más noticias destacadas
+ + +
+ + +
+ + + + + + +
+ + + Egresados de Arquitectura apoyan comunidades afectadas por el terremoto + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ Desde el primer día del terremoto, un amplio grupo de egresados de la Escuela de Arquitectura de…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Beca Fulbright para doctoranda de Univalle + + +
+
+ +
+
+ + +
+ Proyección Internacional
+ + +

+ + + Beca Fulbright para doctoranda de Univalle + +

+ +
+ + + + + + +
+ + + + + + + +
+ La calidad académica y científica, así como su vocación por la transferencia del conocimiento, han llevado a…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Univalle presentó a MinCiencias su balance de infraestructura y proyectos de ciencia y tecnología afectados por el sismo + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ Viceministra de Conocimiento, Innovación y Productividad, Ana Isabel Vargas Salazar; rector de la Universidad del Valle, Guillermo…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + HUV y Facultad de Salud llevaron atención y apoyo humanitario a Buenaventura + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ Docentes, egresados y profesionales vinculados a la Facultad de Salud de la Universidad del Valle participaron en…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Ingenieros de Univalle llegan a El Águila para atender la emergencia por el sismo. + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ Un equipo de ingenieros de la Universidad del Valle se desplazó hasta El Águilla, el municipio vallecaucano…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Comunicado del Consejo Académico + + +
+
+ +
+
+ + +
+ Lo que pasa en la U
+ + +

+ + + Comunicado del Consejo Académico + +

+ +
+ + + + + + +
+ + + + + + + +
+ El Consejo Académico, luego de una larga deliberación sobre la situación actual, considera que como comunidad universitaria…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Enfermedades crónicas y atención en salud sexual en medio de la crisis + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ Las donaciones han sido abundantes, sin embargo, se mantiene la necesidad de productos para garantizar la continuidad…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Facultad de Salud apoya a comunidades afectadas por el sismo del 10 de agosto + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ Desde las primeras horas de la emergencia, docentes, estudiantes, residentes y egresados de la Facultad de Salud…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + ¿Hay riesgo de infecciones después de un terremoto? + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ Debido a la emergencia sísmica que vivió el país, están circulando distintos mensajes que advierten sobre un…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Cómo enfrentar emocionalmente una emergencia + + +
+
+ +
+
+ + +
+ Lo que pasa en la U
+ + +

+ + + Cómo enfrentar emocionalmente una emergencia + +

+ +
+ + + + + + +
+ + + + + + + +
+ El miedo, la ansiedad, la incertidumbre y el insomnio son algunas de las consecuencias que quedan después…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Avanzan valoraciones de infraestructura en el Sistema de Regionalización + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ La Universidad del Valle avanza rápidamente en la valoración de su infraestructura física para ofrecer tranquilidad y…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Estudiantes voluntarios de Salud y Psicología se movilizan para atender la crisis  + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ Durante el Consejo Académico celebrado este jueves 13 de agosto, la decana de la Facultad de Salud,…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Univalle acompaña evaluación de infraestructuras de ciudad tras la emergencia en Cali + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ El pasado 10 de agosto a las 7:30 de la mañana un terremoto de magnitud 7.4 tuvo…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Doctorado en Psicología Social, nuevo posgrado de Univalle + + +
+
+ +
+
+ + +
+ Lo que pasa en la U
+ + +

+ + + Doctorado en Psicología Social, nuevo posgrado de Univalle + +

+ +
+ + + + + + +
+ + + + + + + +
+ El Ministerio de Educación Nacional le otorgó el registro calificado al Doctorado en Psicología Social que ofrecerá…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Cali se prepara para el V Torneo Nacional de Fútbol Femenino + + +
+
+ +
+
+ + +
+ Lo que pasa en la U
+ + +

+ + + Cali se prepara para el V Torneo Nacional de Fútbol Femenino + +

+ +
+ + + + + + +
+ + + + + + + +
+ La Universidad del Valle volverá a convertirse en el principal escenario del fútbol femenino formativo del país…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Récord de participación en el XVIII Torneo de Ajedrez Univalle 2026-1 + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ Los pasados 18 y 25 de julio, se realizó el XVIII Torneo de Ajedrez Integración Universitaria Univalle…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Univalle debuta en la FIL Lima 2026 + + +
+
+ +
+
+ + +
+ Arte y Cultura
+ + +

+ + + Univalle debuta en la FIL Lima 2026 + +

+ +
+ + + + + + +
+ + + + + + + +
+ La universidad colombiana apuesta por el intercambio editorial latinoamericano Tomado de Ensayo GeneralLa Universidad del Valle de…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Univalle fortalece su liderazgo científico con 10 revistas categorizadas en Publindex 2026 + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ La producción científica de una universidad no solo se mide por la investigación que genera, sino también…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Innovación de Univalle recibe patente de invención + + +
+
+ +
+
+ + +
+ Ciencia y Tecnología
+ + +

+ + + Innovación de Univalle recibe patente de invención + +

+ +
+ + + + + + +
+ + + + + + + +
+ La medición precisa de la concentración de gases es un reto para sectores relacionados con el monitoreo…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ + + + + + +
+ + + Ley de canales universitarios recibió sanción del Gobierno Nacional + + +
+
+ +
+ + + + + + + +
+ + + + + + + +
+ Luego de superar cuatro debates en el Congreso y finalizar su trámite en el Senado, el Gobierno…
+
+ + + + + + + +
+
+
+ + + + + +
+ + + +
+ + + + + + +
+
+ +
+
+
+
+ + +
+ + + + +
Página 2 de 446
+ +
+ + + +
+ +
+ + + + +
+

Nuestros Medios

+ + +
+
    +
  • Boton de Univalle TV Canal Universitario
  • +
  • Boton de Univalle Radio
  • +
  • Boton de la Revista Campus Revista Institucional
  • +
  • Boton de la Agencia de noticias Univalle
  • +
+ + +
+

Nuestros Eventos

+ + + + + +
+

Información de la Agencia

+
    +
  • Directora
  • +
  • Fulvia Carvajal
  • +
+
    +
  • Coordinador de Información
  • +
  • Diego Alejandro Guerrero
  • +
+
    +
  • Comunicadores y Periodistas
  • +
  • Laura María Parra
  • +
  • Yizeth Bonilla Vélez
  • +
  • Diana Patricia Sevilla
  • +
  • Edgar Hernán Cruz García
  • +
  • Melissa Pantoja Osorio
  • +
+
    +
  • Contactos
  • +
  • agenda@correounivalle.edu.co
  • +
  • Universidad del Valle
  • +
  • Cali, Colombia
  • +
+ + +
+ + + + + + + +
+
+
+
+
+ + + + + + + + +
+ +
+ + + + + \ No newline at end of file