feat: compose Android app and verify native calendar and reminders

This commit is contained in:
Codex
2026-10-13 09:14:47 +08:00
parent 2c4256b880
commit 5ceab20b67
37 changed files with 695 additions and 229 deletions
+26
View File
@@ -9,6 +9,9 @@ import 'package:hepan_everyday/data/json_schedule_import.dart';
import '../support/fakes.dart';
import '../domain/schedule_engine_test.dart' show course;
import '../services/calendar_service_test.dart' show CalendarProvider;
import 'package:hepan_everyday/services/android_calendar_service.dart';
void main() {
setUpAll(tzdata.initializeTimeZones);
@@ -35,6 +38,29 @@ void main() {
);
});
tearDown(() => controller.dispose());
test('turning off after interrupted first sync cleans events even without saved IDs', () async {
store.value = store.value!.copyWith(
courses: [
course(id: 'a'),
course(id: 'b'),
],
);
final provider = CalendarProvider()..failAfterFirst = true;
final interrupted = AppController(
store,
notifications,
AndroidCalendarService(provider),
controller.clock,
);
await interrupted.initialize();
await interrupted.enableCalendarFromUserAction('local');
expect(interrupted.calendarSyncError, isNotNull);
expect(provider.records, hasLength(1));
provider.failAfterFirst = false;
await interrupted.disableCalendar(deleteEvents: true);
expect(provider.records, isEmpty);
interrupted.dispose();
});
test(
'failed calendar cleanup is retried after reopening while sync is off',
() async {
@@ -0,0 +1,38 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hepan_everyday/services/local_notification_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('transient native initialization failure can be retried', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
AndroidFlutterLocalNotificationsPlugin.registerWith();
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
var attempts = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'initialize') {
attempts++;
if (attempts == 1) {
throw PlatformException(code: 'temporarily-unavailable');
}
return true;
}
if (call.method == 'getNotificationAppLaunchDetails') {
return {'notificationLaunchedApp': false};
}
return null;
});
addTearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
final gateway = PluginReminderGateway();
await expectLater(gateway.initialize(), throwsA(isA<PlatformException>()));
await gateway.initialize();
expect(attempts, 2);
});
}
-30
View File
@@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}
+39
View File
@@ -2,11 +2,44 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:hepan_everyday/app/hepan_app.dart';
import 'package:hepan_everyday/app/app_controller.dart';
import 'package:hepan_everyday/features/profile/settings_page.dart';
import 'package:hepan_everyday/services/calendar_service.dart';
import '../support/fakes.dart';
import 'navigation_test.dart' show uiController;
void main() {
setUpAll(tzdata.initializeTimeZones);
testWidgets('calendar provider failure is explained and keeps switch off', (
tester,
) async {
final controller = AppController(
MemoryStore(),
FakeNotifications(),
UnavailableCalendar(),
DateTime.now,
);
await controller.initialize();
await tester.pumpWidget(
MaterialApp(home: SettingsPage(controller: controller)),
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('导入手机日历'),
250,
scrollable: find.byType(Scrollable).first,
);
await tester.ensureVisible(find.text('导入手机日历'));
await tester.pumpAndSettle();
await tester.tap(find.text('导入手机日历'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('日历暂时不可用,请检查权限后重试'), findsOneWidget);
expect(controller.snapshot.settings.calendarEnabled, isFalse);
controller.dispose();
});
testWidgets(
'invalid semester weeks are rejected without changing saved data',
(tester) async {
@@ -57,3 +90,9 @@ void main() {
controller.dispose();
});
}
class UnavailableCalendar extends FakeCalendar {
@override
Future<List<DeviceCalendar>> listWritableCalendars() async =>
throw StateError('unavailable');
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:hepan_everyday/main.dart';
import 'package:hepan_everyday/app/app_controller.dart';
import 'package:hepan_everyday/app/hepan_app.dart';
import 'package:hepan_everyday/domain/app_snapshot.dart';
import 'package:hepan_everyday/domain/app_settings.dart';
import 'package:hepan_everyday/domain/semester.dart';
import '../support/fakes.dart';
import '../domain/schedule_engine_test.dart' show course;
void main() {
setUpAll(tzdata.initializeTimeZones);
testWidgets(
'actual startup connects both tabs and reminder tap opens saved course',
(tester) async {
final selection = ValueNotifier<String?>(null);
final controller = AppController(
MemoryStore(
AppSnapshot(
courses: [course(last: 2)],
settings: AppSettings(
semester: Semester(
startDate: DateTime(2026, 9, 9),
totalWeeks: 5,
),
),
),
),
FakeNotifications(),
FakeCalendar(),
() => DateTime.utc(2026, 9, 8),
);
await controller.initialize();
await tester.pumpWidget(
buildApplication(controller, notificationSelection: selection),
);
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('课表'), findsOneWidget);
expect(find.bySemanticsLabel('我的'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('我的'));
await tester.pumpAndSettle();
selection.value = 'math/2026-09-09';
await tester.pumpAndSettle();
expect(find.text('知道了'), findsOneWidget);
expect(find.textContaining('08:30–10:05'), findsOneWidget);
await tester.tap(find.text('知道了'));
await tester.pumpAndSettle();
expect(find.byType(HepanApp), findsOneWidget);
controller.dispose();
selection.dispose();
},
);
}