feat(flutter): 增加轮播自动播放与站内通知
This commit is contained in:
1 parent
b7b1c50427
commit
b870956665
24 files changed
+1398
-26
No files matched your search
@@ -0,0 +1,52 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/notice_api.dart';
|
||||
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
void main() {
|
||||
test('should_parse_notice_page_with_string_ids_when_requested', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {
|
||||
'total': '3',
|
||||
'list': [
|
||||
{
|
||||
'id': '2111012753262120962',
|
||||
'title': 'Maintenance',
|
||||
'content': 'Line one\nLine two',
|
||||
'type': 'SYSTEM',
|
||||
'publishAt': '2026-10-17T12:30:00',
|
||||
'expireAt': null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
final api = NoticeApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final page = await api.page(pageIndex: 2, pageSize: 20);
|
||||
|
||||
expect(adapter.requests.single.uri.path, '/app/notices');
|
||||
expect(adapter.requests.single.queryParameters, {
|
||||
'pageIndex': '2',
|
||||
'pageSize': '20',
|
||||
});
|
||||
expect(page.total, 3);
|
||||
expect(page.notices.single.id, '2111012753262120962');
|
||||
expect(page.notices.single.content, 'Line one\nLine two');
|
||||
expect(page.notices.single.type, 'SYSTEM');
|
||||
expect(page.notices.single.publishAt, DateTime(2026, 10, 17, 12, 30));
|
||||
expect(page.notices.single.expireAt, isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/notice.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/notice_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/notices_controller.dart';
|
||||
|
||||
void main() {
|
||||
test('should_append_next_page_until_loaded_count_reaches_total', () async {
|
||||
final repository = ScriptedNoticeRepository([
|
||||
NoticePage(total: 3, notices: [_notice('1'), _notice('2')]),
|
||||
NoticePage(total: 3, notices: [_notice('3')]),
|
||||
]);
|
||||
final controller = NoticesController(repository);
|
||||
|
||||
await controller.load();
|
||||
expect(controller.notices.map((item) => item.id), ['1', '2']);
|
||||
expect(controller.hasMore, isTrue);
|
||||
|
||||
await controller.loadMore();
|
||||
expect(controller.notices.map((item) => item.id), ['1', '2', '3']);
|
||||
expect(controller.hasMore, isFalse);
|
||||
expect(repository.calls.map((call) => call.pageIndex), [1, 2]);
|
||||
});
|
||||
|
||||
test('should_show_empty_state_when_total_is_zero', () async {
|
||||
final controller = NoticesController(
|
||||
ScriptedNoticeRepository([const NoticePage(total: 0)]),
|
||||
);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.loading, isFalse);
|
||||
expect(controller.notices, isEmpty);
|
||||
expect(controller.hasMore, isFalse);
|
||||
expect(controller.error, isNull);
|
||||
});
|
||||
|
||||
test('should_preserve_backend_message_when_page_load_fails', () async {
|
||||
final repository = ScriptedNoticeRepository([])
|
||||
..error = const ApiException(500, '公告服务暂不可用');
|
||||
final controller = NoticesController(repository);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.error, '公告服务暂不可用');
|
||||
expect(controller.notices, isEmpty);
|
||||
});
|
||||
|
||||
test('should_replace_list_when_refreshed', () async {
|
||||
final repository = ScriptedNoticeRepository([
|
||||
NoticePage(total: 2, notices: [_notice('1'), _notice('2')]),
|
||||
NoticePage(total: 1, notices: [_notice('9')]),
|
||||
]);
|
||||
final controller = NoticesController(repository);
|
||||
await controller.load();
|
||||
|
||||
await controller.refresh();
|
||||
|
||||
expect(controller.notices.map((item) => item.id), ['9']);
|
||||
expect(controller.hasMore, isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
AppNotice _notice(String id) => AppNotice(
|
||||
id: id,
|
||||
title: 'Notice $id',
|
||||
content: 'Content $id',
|
||||
type: 'SYSTEM',
|
||||
publishAt: DateTime(2026, 10, 17, 12),
|
||||
);
|
||||
|
||||
class ScriptedNoticeRepository implements NoticeRepository {
|
||||
ScriptedNoticeRepository(this.pages);
|
||||
|
||||
final List<NoticePage> pages;
|
||||
final List<NoticePageCall> calls = [];
|
||||
Object? error;
|
||||
|
||||
@override
|
||||
Future<NoticePage> page({required int pageIndex, int pageSize = 20}) async {
|
||||
calls.add(NoticePageCall(pageIndex: pageIndex, pageSize: pageSize));
|
||||
if (error != null) throw error!;
|
||||
if (pages.isEmpty) return const NoticePage(total: 0);
|
||||
return pages.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
class NoticePageCall {
|
||||
const NoticePageCall({required this.pageIndex, required this.pageSize});
|
||||
|
||||
final int pageIndex;
|
||||
final int pageSize;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/data/models/notice.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import '../support/fake_catalog.dart';
|
||||
import '../support/fake_category.dart';
|
||||
import '../support/fake_interaction_repositories.dart';
|
||||
import '../support/fake_notice_repository.dart';
|
||||
import '../support/widget_player.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_open_notices_from_mine_menu_without_login_gate', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final catalog = FakeCatalogRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(
|
||||
playerFactory: createWidgetPlayer,
|
||||
catalogRepository: catalog,
|
||||
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
||||
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
||||
favoriteRepository: InMemoryFavoriteRepository(),
|
||||
playbackRepository: FakePlaybackRepository(),
|
||||
commentRepository: FakeCommentRepository(),
|
||||
interactionRepository: FakeInteractionRepository(),
|
||||
noticeRepository: FakeNoticeRepository(
|
||||
pages: [const NoticePage(total: 0)],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('mine-notices')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('mine-notices')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('notices-page')), findsOneWidget);
|
||||
expect(find.byKey(const Key('auth-gate-panel')), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/notice.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/notice_repository.dart';
|
||||
import 'package:fruitshorts/core/formatting/localized_date_time.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/notices/notices_page.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../support/fake_notice_repository.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_render_first_page_and_append_next_page', (tester) async {
|
||||
final repository = FakeNoticeRepository(
|
||||
pages: [
|
||||
NoticePage(total: 3, notices: [_notice('1'), _notice('2')]),
|
||||
NoticePage(total: 3, notices: [_notice('3')]),
|
||||
],
|
||||
);
|
||||
await _pump(tester, repository);
|
||||
|
||||
expect(find.text('Notice 1'), findsOneWidget);
|
||||
expect(find.byKey(const Key('notices-load-more')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('notices-load-more')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Notice 3'), findsOneWidget);
|
||||
expect(repository.calls.last.pageIndex, 2);
|
||||
});
|
||||
|
||||
testWidgets('should_show_empty_state_when_no_notices_exist', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
FakeNoticeRepository(pages: [const NoticePage(total: 0)]),
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('notices-empty')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_show_backend_error_and_retry_when_page_fails', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
FakeNoticeRepository(error: const ApiException(500, '公告加载失败')),
|
||||
);
|
||||
|
||||
expect(find.text('公告加载失败'), findsOneWidget);
|
||||
expect(find.byKey(const Key('notices-retry')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_show_loading_state_before_first_page_arrives', (
|
||||
tester,
|
||||
) async {
|
||||
final repository = FakeNoticeRepository(
|
||||
pages: [const NoticePage(total: 0)],
|
||||
delay: const Duration(milliseconds: 50),
|
||||
);
|
||||
await tester.pumpWidget(_app(repository));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const Key('notices-loading')), findsOneWidget);
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('should_open_detail_with_newlines_and_indonesian_time', (
|
||||
tester,
|
||||
) async {
|
||||
final notice = _notice(
|
||||
'2111012753262120962',
|
||||
content: 'Line one\nLine two',
|
||||
);
|
||||
await _pump(
|
||||
tester,
|
||||
FakeNoticeRepository(
|
||||
pages: [
|
||||
NoticePage(total: 1, notices: [notice]),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('notice-item-2111012753262120962')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('notice-detail-close')), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<SelectableText>(
|
||||
find.byKey(const Key('notice-detail-content')),
|
||||
)
|
||||
.data,
|
||||
'Line one\nLine two',
|
||||
);
|
||||
final context = tester.element(
|
||||
find.byKey(const Key('notice-detail-content')),
|
||||
);
|
||||
final expected = formatLocalizedDateTime(context, notice.publishAt!);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.text(expected),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_scroll_long_detail_without_overflow_on_small_screen', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(320, 568);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final notice = _notice(
|
||||
'1',
|
||||
title: 'A very long notice title that wraps across several lines',
|
||||
content: List.filled(40, 'Long notice content line.').join('\n'),
|
||||
);
|
||||
await _pump(
|
||||
tester,
|
||||
FakeNoticeRepository(
|
||||
pages: [
|
||||
NoticePage(total: 1, notices: [notice]),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('notice-item-1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('notice-detail-content')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
AppNotice _notice(
|
||||
String id, {
|
||||
String? title,
|
||||
String content = 'Notice content',
|
||||
}) => AppNotice(
|
||||
id: id,
|
||||
title: title ?? 'Notice $id',
|
||||
content: content,
|
||||
type: 'SYSTEM',
|
||||
publishAt: DateTime(2026, 10, 17, 12, 30),
|
||||
);
|
||||
|
||||
Future<void> _pump(WidgetTester tester, NoticeRepository repository) async {
|
||||
await tester.pumpWidget(_app(repository));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Widget _app(NoticeRepository repository) => Provider<NoticeRepository>.value(
|
||||
value: repository,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('id'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const NoticesPage(),
|
||||
),
|
||||
);
|
||||
Reference in new issue
Block a user