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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import 'dart:convert';

import 'package:dio/dio.dart';
import 'package:html/dom.dart';
import 'package:html/parser.dart' show parse;
import 'package:html/parser.dart' show parse, parseFragment;

import '../../../../core/constants/schedule_constants.dart';
import '../../../../core/error/exceptions.dart';
Expand All @@ -17,10 +17,12 @@ class SiraScheduleRemoteDataSource {
const SiraScheduleRemoteDataSource(this._dio);

// Matches a session's day/time/building line when a room is assigned,
// e.g. "JUE: 14:00-17:00 , Edf. B13". Hours may be one or two digits
// ("8:00" as well as "14:00").
// e.g. "JUE: 14:00-17:00 , Edf. B13 (B13) -> SALA 1 -- MG -- MELENDEZ".
// Hours may be one or two digits ("8:00" as well as "14:00"). The
// location detail after the building code is optional and, when
// present, captured whole in group 5 for `_locationLine` to parse.
static final _sessionWithBuilding = RegExp(
r'^(LUN|MAR|MI[EÉ]|JUE|VIE|SAB|DOM):\s*(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})\s*,\s*Edf\.\s*(.+)$',
r'^(LUN|MAR|MI[EÉ]|JUE|VIE|SAB|DOM):\s*(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})\s*,\s*Edf\.\s*(\S+)\s*(\(.*)?$',
caseSensitive: false,
);

Expand All @@ -32,8 +34,9 @@ class SiraScheduleRemoteDataSource {
caseSensitive: false,
);

// Matches the location line that follows a `_sessionWithBuilding` line,
// e.g. "(B13) -> SALA 1 -- MG -- MELENDEZ".
// Matches the location detail, e.g. "(B13) -> SALA 1 -- MG -- MELENDEZ" —
// usually on the same line as `_sessionWithBuilding` right after the
// building code, occasionally (defensively handled) on the next line.
static final _locationLine = RegExp(
r'^\([^)]*\)\s*->\s*(.+?)\s*--\s*(.+?)\s*--\s*(.+)$',
);
Expand Down Expand Up @@ -76,11 +79,11 @@ class SiraScheduleRemoteDataSource {
if (cells.length != 9) return const [];
if (cells[2].text.trim() != subject.group) return const [];

final scheduleText = cells[4].text.trim();
if (scheduleText.isEmpty) return const [];
final scheduleLines = _splitByBreaks(cells[4]);
if (scheduleLines.isEmpty) return const [];

final teacher = _parseTeacher(cells[5].text.trim());
return _parseSessions(scheduleText)
return _parseSessions(scheduleLines)
.map(
(session) => ScheduleClassModel(
subjectCode: subject.code,
Expand All @@ -99,26 +102,37 @@ class SiraScheduleRemoteDataSource {
.toList();
}

// The schedule cell packs one or more sessions. When a room is assigned
// it's an alternating pair of lines: "JUE: 14:00-17:00 , Edf. B13" then
// "(B13) -> SALA 1 -- MG -- MELENDEZ" (the middle "--" segment is an
// internal room-type code that isn't meaningful to show the student).
// When it isn't, it's a single line: "JUE: 8:00-12:00 ,SIN ESPACIO -- MG".
List<_ParsedSession> _parseSessions(String rawText) {
final lines = rawText
.split('\n')
.map((line) => line.trim())
.where((line) => line.isNotEmpty)
.toList();

// The schedule cell packs one or more sessions separated by <br> tags
// rather than actual newlines in the source HTML, so lines must be split
// on the markup itself — splitting the extracted text on '\n' leaves
// every session glued into one string with no separator to find.
List<String> _splitByBreaks(Element cell) => cell.innerHtml
.split(RegExp(r'<br\s*/?>', caseSensitive: false))
.map((fragment) => parseFragment(fragment).text?.trim() ?? '')
.where((line) => line.isNotEmpty)
.toList();

// The schedule cell packs one or more sessions, each its own <br>-
// separated line: "JUE: 14:00-17:00 , Edf. B13 (B13) -> SALA 1 -- MG --
// MELENDEZ" (the middle "--" segment is an internal room-type code that
// isn't meaningful to show the student). When no room is assigned yet,
// it's just "JUE: 8:00-12:00 ,SIN ESPACIO -- MG" with no location detail.
List<_ParsedSession> _parseSessions(List<String> lines) {
final sessions = <_ParsedSession>[];
var i = 0;
while (i < lines.length) {
final withBuilding = _sessionWithBuilding.firstMatch(lines[i]);
if (withBuilding != null) {
var room = '';
var campus = '';
if (i + 1 < lines.length) {
final sameLineLocation = withBuilding.group(5);
if (sameLineLocation != null) {
final locationMatch = _locationLine.firstMatch(sameLineLocation);
if (locationMatch != null) {
room = locationMatch.group(1)!.trim();
campus = locationMatch.group(3)!.trim();
}
} else if (i + 1 < lines.length) {
final locationMatch = _locationLine.firstMatch(lines[i + 1]);
if (locationMatch != null) {
room = locationMatch.group(1)!.trim();
Expand Down
36 changes: 36 additions & 0 deletions test/core/error/exceptions_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:univalle_app/core/error/exceptions.dart';

void main() {
test('ServerException carries an optional status code', () {
final exception = ServerException(message: 'boom', statusCode: 500);
expect(exception.message, 'boom');
expect(exception.statusCode, 500);
});

test('NetworkException carries its message', () {
expect(NetworkException(message: 'boom').message, 'boom');
});

test('CacheException carries its message', () {
expect(CacheException(message: 'boom').message, 'boom');
});

test('AuthException carries its message', () {
expect(AuthException(message: 'boom').message, 'boom');
});

test('BusinessException defaults to retryable', () {
final exception = BusinessException(message: 'boom');
expect(exception.retryable, isTrue);
});

test('BusinessException can be marked as non-retryable', () {
final exception = BusinessException(message: 'boom', retryable: false);
expect(exception.retryable, isFalse);
});

test('toString returns the runtime type', () {
expect(NetworkException(message: 'boom').toString(), 'NetworkException');
});
}
7 changes: 7 additions & 0 deletions test/core/error/failures_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,11 @@ void main() {
final failure = BusinessFailure(message: 'x', retryable: false);
expect(failure.retryable, isFalse);
});

test('toString includes the internal message', () {
expect(
NetworkFailure(message: 'boom').toString(),
'Failure(message: boom)',
);
});
}
100 changes: 100 additions & 0 deletions test/core/storage/shared_preferences_service_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:univalle_app/core/error/exceptions.dart';
import 'package:univalle_app/core/storage/shared_preferences_service.dart';

class MockSharedPreferences extends Mock implements SharedPreferences {}

void main() {
late MockSharedPreferences preferences;
late SharedPreferencesService service;

setUp(() {
preferences = MockSharedPreferences();
service = SharedPreferencesService(preferences);
});

test('getString reads through to SharedPreferences', () async {
when(() => preferences.getString('k')).thenReturn('v');
expect(await service.getString('k'), 'v');
});

test('setString writes through to SharedPreferences', () async {
when(() => preferences.setString('k', 'v')).thenAnswer((_) async => true);
await service.setString('k', 'v');
verify(() => preferences.setString('k', 'v')).called(1);
});

test('getBool reads through to SharedPreferences', () async {
when(() => preferences.getBool('k')).thenReturn(true);
expect(await service.getBool('k'), isTrue);
});

test('setBool writes through to SharedPreferences', () async {
when(() => preferences.setBool('k', true)).thenAnswer((_) async => true);
await service.setBool('k', true);
verify(() => preferences.setBool('k', true)).called(1);
});

test('getInt reads through to SharedPreferences', () async {
when(() => preferences.getInt('k')).thenReturn(42);
expect(await service.getInt('k'), 42);
});

test('setInt writes through to SharedPreferences', () async {
when(() => preferences.setInt('k', 42)).thenAnswer((_) async => true);
await service.setInt('k', 42);
verify(() => preferences.setInt('k', 42)).called(1);
});

test('getDouble reads through to SharedPreferences', () async {
when(() => preferences.getDouble('k')).thenReturn(4.2);
expect(await service.getDouble('k'), 4.2);
});

test('setDouble writes through to SharedPreferences', () async {
when(() => preferences.setDouble('k', 4.2)).thenAnswer((_) async => true);
await service.setDouble('k', 4.2);
verify(() => preferences.setDouble('k', 4.2)).called(1);
});

test('getStringList reads through to SharedPreferences', () async {
when(() => preferences.getStringList('k')).thenReturn(['a', 'b']);
expect(await service.getStringList('k'), ['a', 'b']);
});

test('setStringList writes through to SharedPreferences', () async {
when(() => preferences.setStringList('k', ['a', 'b']))
.thenAnswer((_) async => true);
await service.setStringList('k', ['a', 'b']);
verify(() => preferences.setStringList('k', ['a', 'b'])).called(1);
});

test('remove delegates to SharedPreferences', () async {
when(() => preferences.remove('k')).thenAnswer((_) async => true);
await service.remove('k');
verify(() => preferences.remove('k')).called(1);
});

test('clear delegates to SharedPreferences', () async {
when(() => preferences.clear()).thenAnswer((_) async => true);
await service.clear();
verify(() => preferences.clear()).called(1);
});

test('wraps any thrown error as a CacheException', () async {
when(() => preferences.getString('k')).thenThrow(Exception('disk full'));

await expectLater(
() => service.getString('k'),
throwsA(
isA<CacheException>().having(
(e) => e.message,
'message',
contains('disk full'),
),
),
);
});
}
Loading