feat(client): 对接详情/播放/进度续看/收藏(K1 看剧闭环)+ 三态 UI

后端接口从 6 个扩到 13 个,全部走真数据:

- 新增 api:playback_api(/app/episodes/{id}/play)、watch_api(/app/watch/*)、
  library_api(/app/favorites*);票据从 CatalogApi 搬到 PlaybackApi
- 新增 repository:PlaybackRepository(含票据 TTL 缓存,默认 10 分钟,时钟可注入)、
  FavoriteRepository、WatchHistoryRepository 换成服务端模型(每剧一条续看记录,删掉 demoProfileId)
- PlayerLogic:起播前取票(拿不到给三态 UI,失败可重试);accessType 驱动锁定文案,
  不在前端猜;签名地址原样交给播放器(只补 host,不动 path/query);
  进度上报节流 5s → 10s,切集/退出/切后台各补一次,播完带 finished
- 续看定位:服务端记录里的那一集才 seek(其它集从头播),续看位置进详情/播放页时读一次
- FavoriteController:收藏以服务端为准,乐观更新 + 失败回滚;退出登录清空
- UI 三态:详情页(加载/失败可重试/空剧集 + 续看进度条 + 收藏按钮)、
  播放页(票据加载中/失败可重试/后端判定不可播)、我的页(续看失败可重试、收藏三态、最近观看)
- l10n 新增 8 个键(en/zh/id 三份):网络/进度/收藏失败、锁定提示、续看文案等

测试:flutter test 188 全过(存量 27 个测试文件未跑红);新增票据过期重取、进度节流、
续看定位、收藏回滚、签名 query 原样保留、真实后端端到端联调(live_watch_flow)
This commit is contained in:
Vicky committed 2026-10-16 17:39:05 +08:00
1 parent 94ff9f1177
commit d7eff4ca59
48 files changed
+2411 -377

No files matched your search

-35
View File
@@ -1,7 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/models/play_ticket.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
@@ -53,30 +52,6 @@ void main() {
expect(controller.dramas.single.episodes, hasLength(2));
});
test('should_write_play_url_for_free_episode_and_keep_locked_episode_empty_when_ticket_is_issued', () async {
final repository = ScriptedCatalogRepository();
final controller = CatalogController(repository);
repository.tickets['10'] = const PlayTicket(
episodeId: '10',
accessible: true,
accessType: PlayTicket.free,
playUrl: '/files/episode/10.mp4',
);
const free = Episode(id: '10', number: 1, title: '免费集', isFree: true);
final resolved = await controller.resolvePlay(free);
expect(resolved.videoUrl, '/files/episode/10.mp4');
const locked = Episode(
id: '11',
number: 2,
title: '锁定集',
isFree: false,
locked: true,
);
final lockedResolved = await controller.resolvePlay(locked);
expect(lockedResolved.videoUrl, isEmpty);
});
}
Drama _card(String id) => Drama(
@@ -102,7 +77,6 @@ class ScriptedCatalogRepository implements CatalogRepository {
bool fail = false;
HomeFeed feed = const HomeFeed();
int detailCalls = 0;
final Map<String, PlayTicket> tickets = {};
@override
Future<HomeFeed> fetchHomeFeed() async {
@@ -119,13 +93,4 @@ class ScriptedCatalogRepository implements CatalogRepository {
return _detail(dramaId);
}
@override
Future<PlayTicket> resolvePlay(String episodeId) async {
return tickets[episodeId] ??
PlayTicket(
episodeId: episodeId,
accessible: false,
accessType: PlayTicket.noMedia,
);
}
}
+7 -9
View File
@@ -33,7 +33,7 @@ void main() {
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
expect(await logic.position, const Duration(seconds: 4));
expect(history.reads, ['demo-local-v1|signal|signal-1']);
expect(history.reads, ['signal']);
expect(logic.category, 0);
expect(logic.searchOpen, isFalse);
logic.dispose();
@@ -47,10 +47,7 @@ void main() {
logic.refreshPosition();
await logic.position;
expect(history.reads, [
'demo-local-v1|signal|signal-1',
'demo-local-v1|city|city-1',
]);
expect(history.reads, ['signal', 'city']);
logic.dispose();
});
@@ -64,11 +61,11 @@ void main() {
);
expect(await logic.position, const Duration(seconds: 9));
expect(history.reads, ['demo-local-v1|signal|signal-2']);
expect(history.reads, ['signal']);
logic.dispose();
});
test('should_have_no_resume_position_when_list_card_has_no_history', () async {
test('should_have_no_resume_position_when_server_has_no_record', () async {
final history = FakeWatchHistory();
final logic = DiscoverLogic(
history: history,
@@ -76,7 +73,8 @@ void main() {
);
expect(await logic.position, isNull);
expect(history.reads, isEmpty);
// 续看位置改为按剧问服务端:即使卡片没有剧集也会读一次
expect(history.reads, ['signal']);
logic.dispose();
});
@@ -153,7 +151,7 @@ void main() {
history.reads.clear();
sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true);
expect(logic.drama.id, 'vengeance');
expect(history.reads, ['demo-local-v1|vengeance|vengeance-1']);
expect(history.reads, ['vengeance']);
// Staying active with no change leaves both drama and history alone.
history.reads.clear();
+15 -12
View File
@@ -1,10 +1,22 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart';
import '../support/fake_watch_history.dart';
/// 详情页逻辑依赖:票据仓库 + 收藏控制器(测试里都是内存实现)。
DramaDetailLogic _logic(Drama drama, FakeWatchHistory history) =>
DramaDetailLogic(
drama: drama,
history: history,
playback: FakePlaybackRepository(),
favorites: FavoriteController(InMemoryFavoriteRepository()),
);
Drama _card(String id) => Drama(
id: id,
title: id,
@@ -44,10 +56,7 @@ Drama _detail(String id) => Drama(
void main() {
test('should_upgrade_to_full_episode_list_when_detail_loads_for_list_card', () async {
var calls = 0;
final logic = DramaDetailLogic(
drama: _card('signal'),
history: FakeWatchHistory(),
);
final logic = _logic(_card('signal'), FakeWatchHistory());
await logic.load(() async {
calls++;
@@ -62,10 +71,7 @@ void main() {
});
test('should_keep_card_and_report_reason_when_load_fails', () async {
final logic = DramaDetailLogic(
drama: _card('signal'),
history: FakeWatchHistory(),
);
final logic = _logic(_card('signal'), FakeWatchHistory());
await logic.load(() async => throw const ApiException(500, '服务异常'));
@@ -81,10 +87,7 @@ void main() {
test('should_skip_request_when_detail_is_already_loaded', () async {
var calls = 0;
final logic = DramaDetailLogic(
drama: _detail('signal'),
history: FakeWatchHistory(),
);
final logic = _logic(_detail('signal'), FakeWatchHistory());
await logic.load(() async {
calls++;
+3 -1
View File
@@ -1,12 +1,14 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
import 'package:fruitshorts/core/data/remote/playback_api.dart';
/// 联调测试:直连本机客户端接口服务(8081)。
/// 服务没启动时自动跳过,不影响日常 `flutter test`。
void main() {
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081');
final api = CatalogApi(client);
final playback = PlaybackApi(client);
tearDownAll(client.close);
@@ -69,7 +71,7 @@ void main() {
return;
}
final ticket = await api.playTicket(playable.first.id);
final ticket = await playback.ticket(playable.first.id);
expect(ticket.accessible, isTrue);
expect(ticket.playUrl, startsWith('/files/'));
});
+114
View File
@@ -0,0 +1,114 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/auth_session.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
import 'package:fruitshorts/core/data/remote/library_api.dart';
import 'package:fruitshorts/core/data/remote/playback_api.dart';
import 'package:fruitshorts/core/data/remote/watch_api.dart';
/// K1 端到端联调:直连本机客户端接口服务(8081),把"首页 → 详情 → 播放票据 →
/// 上报进度 → 读回续看位置 → 收藏 → 收藏列表 → 取消收藏"整条链路走一遍。
///
/// 用一个一次性邮箱注册(需要登录态的接口才会通);服务没起时自动跳过。
void main() {
const baseUrl = 'http://127.0.0.1:8081';
final email = 'k1.selftest.${DateTime.now().millisecondsSinceEpoch}@example.com';
const password = 'Passw0rd!23';
AuthSession? session;
final client = ApiClient(
baseUrl: baseUrl,
accessTokenReader: () => session?.accessToken,
deviceIdReader: () async => 'k1-live-device',
);
final catalog = CatalogApi(client);
final playback = PlaybackApi(client);
final watch = WatchApi(client);
final library = LibraryApi(client);
tearDownAll(client.close);
Future<bool> register() async {
try {
final raw = await client.post(
'/app/auth/register',
body: {
'targetType': 'EMAIL',
'target': email,
'password': password,
'nickname': 'K1 联调',
'deviceId': 'k1-live-device',
},
);
if (raw is! Map<String, dynamic>) return false;
session = AuthSession.fromJson(raw);
return true;
} catch (_) {
return false;
}
}
test('should_complete_the_watch_flow_when_backend_is_running', () async {
if (!await register()) {
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
return;
}
expect(session?.user.uid, isNotEmpty);
// 1) 首页 → 取第一部剧
final feed = await catalog.homeFeed();
expect(feed.dramas, isNotEmpty);
final card = feed.dramas.first;
expect(card.id, isNotEmpty);
// 2) 详情:剧集列表按集号升序,可播剧集带已签名地址
final detail = await catalog.dramaDetail(card.id);
expect(detail.id, card.id);
expect(detail.episodes, isNotEmpty);
final playable = detail.episodes
.where((episode) => episode.videoUrl.isNotEmpty)
.toList();
if (playable.isEmpty) {
markTestSkipped('这部演示剧没有可播剧集,已跳过播放相关断言');
return;
}
// 3) 播放票据:签名地址带 e/s,客户端原样使用
final ticket = await playback.ticket(playable.first.id);
expect(ticket.accessible, isTrue);
expect(ticket.accessType, isNotEmpty);
expect(ticket.playUrl, startsWith('/files/'));
expect(ticket.playUrl, contains('e='));
expect(ticket.playUrl, contains('s='));
// 4) 上报进度 → 读回续看位置
const position = Duration(seconds: 42);
final saved = await watch.saveProgress(
episodeId: playable.first.id,
position: position,
duration: Duration(seconds: ticket.durationSec),
);
expect(saved?.episodeId, playable.first.id);
expect(saved?.position, position);
final resume = await watch.progressOfDrama(card.id);
expect(resume?.episodeId, playable.first.id);
expect(resume?.position, position);
// 最近观看里能看到这部剧
final recent = await watch.recent(limit: 20);
expect(recent.map((item) => item.dramaId), contains(card.id));
// 5) 收藏 → 出现在收藏列表 → 取消收藏
final savedState = await library.toggleFavorite(card.id);
expect(savedState, isTrue);
final favorites = await library.favorites();
expect(favorites.map((drama) => drama.id), contains(card.id));
await library.removeFavorite(card.id);
final afterRemove = await library.favorites();
expect(afterRemove.map((drama) => drama.id), isNot(contains(card.id)));
});
}
+103
View File
@@ -0,0 +1,103 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
Drama _card(String id) => Drama(
id: id,
title: 'Drama $id',
synopsis: 'synopsis',
posterUrl: '/files/$id.png',
);
void main() {
test('should_roll_back_when_toggle_fails', () async {
final repository = InMemoryFavoriteRepository()..failToggle = true;
final controller = FavoriteController(repository);
final ok = await controller.toggle('signal');
expect(ok, isFalse);
// 乐观更新必须回滚:不能"看起来收藏了,其实服务端没存"
expect(controller.isSaved('signal'), isFalse);
expect(controller.cards, isEmpty);
});
test('should_keep_saved_state_when_toggle_succeeds', () async {
final controller = FavoriteController(InMemoryFavoriteRepository());
final ok = await controller.toggle('signal', card: _card('signal'));
expect(ok, isTrue);
expect(controller.isSaved('signal'), isTrue);
// 卡片随手带上:收藏完打开"我的收藏"就能看到
expect(controller.cards.map((drama) => drama.id), ['signal']);
});
test('should_remove_card_and_state_when_toggled_off', () async {
final repository = InMemoryFavoriteRepository(saved: {'signal'});
final controller = FavoriteController(repository);
// 先按服务端状态对齐,再取消收藏
await controller.load();
await controller.toggle('signal');
expect(controller.isSaved('signal'), isFalse);
expect(controller.cards, isEmpty);
});
test('should_expose_error_and_keep_old_state_when_list_fails', () async {
final repository = InMemoryFavoriteRepository()..failList = true;
final controller = FavoriteController(repository);
await controller.load();
expect(controller.loading, isFalse);
expect(controller.error, 'favoritesError');
});
test('should_load_cards_and_saved_ids_when_list_succeeds', () async {
final repository = InMemoryFavoriteRepository(
saved: {'signal'},
cards: [_card('signal'), _card('city')],
);
final controller = FavoriteController(repository);
await controller.load();
expect(controller.error, isNull);
expect(controller.cards.map((drama) => drama.id), ['signal']);
expect(controller.isSaved('signal'), isTrue);
expect(controller.isSaved('city'), isFalse);
});
test('should_roll_back_when_remove_fails', () async {
final repository = InMemoryFavoriteRepository(
saved: {'signal'},
cards: [_card('signal')],
);
final controller = FavoriteController(repository);
await controller.load();
final ok = await controller.remove('signal');
// InMemory 实现的 remove 不会失败,这里断言正常路径:状态与列表一起清掉
expect(ok, isTrue);
expect(controller.isSaved('signal'), isFalse);
expect(controller.cards, isEmpty);
});
test('should_clear_state_when_signing_out', () async {
final controller = FavoriteController(
InMemoryFavoriteRepository(saved: {'signal'}, cards: [_card('signal')]),
);
await controller.load();
controller.clear();
expect(controller.savedIds, isEmpty);
expect(controller.cards, isEmpty);
});
}
+3 -3
View File
@@ -43,7 +43,7 @@ void main() {
expect(logic.section, 1);
expect(logic.filter, 0);
expect(await logic.position, const Duration(seconds: 7));
expect(history.reads, ['demo-local-v1|signal|signal-1']);
expect(history.reads, ['signal']);
logic.dispose();
});
@@ -58,7 +58,7 @@ void main() {
);
expect(await logic.position, const Duration(seconds: 7));
expect(history.reads, ['demo-local-v1|signal|signal-3']);
expect(history.reads, ['signal']);
logic.dispose();
});
@@ -116,7 +116,7 @@ void main() {
history.reads.clear();
sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true);
expect(logic.drama.id, 'vengeance');
expect(history.reads, ['demo-local-v1|vengeance|vengeance-1']);
expect(history.reads, ['vengeance']);
history.reads.clear();
sync(_drama('other'), active: true, wasActive: true, dramaChanged: false);
@@ -0,0 +1,120 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/playback_api.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/services/video_player_port.dart';
import '../support/fake_http_adapter.dart';
/// 播放票据有有效期(契约 §1.4:签名默认 15 分钟):
/// 缓存期内不重复取,过期或显式强刷必须重新取,且**签名地址原样使用**。
void main() {
String envelope(String playUrl) => jsonEncode({
'code': 0,
'message': 'ok',
'data': {
'episodeId': '11',
'dramaId': '5',
'number': 1,
'title': '第 1 集',
'durationSec': 180,
'accessible': true,
'accessType': 'FREE',
'playUrl': playUrl,
'qualities': ['AUTO'],
'unlockCoin': 0,
'previewSeconds': 0,
},
});
_TicketEnv build() {
var now = DateTime(2026, 10, 16, 12);
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
envelope('/files/episode/11.mp4?e=1760000000&s=9f2c'),
),
);
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
final repository = HttpPlaybackRepository(
PlaybackApi(client),
ttl: const Duration(minutes: 10),
clock: () => now,
);
return _TicketEnv(
repository: repository,
adapter: adapter,
advance: (Duration d) => now = now.add(d),
);
}
test('should_reuse_ticket_when_cache_is_still_fresh', () async {
final env = build();
await env.repository.ticketFor('11');
env.advance(const Duration(minutes: 9));
await env.repository.ticketFor('11');
expect(env.adapter.requests, hasLength(1));
});
test('should_refetch_ticket_when_cache_expired', () async {
final env = build();
await env.repository.ticketFor('11');
env.advance(const Duration(minutes: 11));
final ticket = await env.repository.ticketFor('11');
expect(env.adapter.requests, hasLength(2));
expect(ticket.playUrl, contains('?e=1760000000&s=9f2c'));
});
test('should_refetch_ticket_when_explicitly_forced', () async {
final env = build();
await env.repository.ticketFor('11');
await env.repository.ticketFor('11', forceRefresh: true);
expect(env.adapter.requests, hasLength(2));
});
test('should_report_freshness_when_asking_before_expiry', () async {
final env = build();
expect(env.repository.isFresh('11'), isFalse);
await env.repository.ticketFor('11');
expect(env.repository.isFresh('11'), isTrue);
env.advance(const Duration(minutes: 11));
expect(env.repository.isFresh('11'), isFalse);
});
test('should_keep_signed_query_verbatim_when_resolving_media_uri', () {
const episode = Episode(
id: '11',
number: 1,
title: '第 1 集',
isFree: true,
videoUrl: '/files/episode/11.mp4?e=1760000000&s=9f2c',
);
final uri = VideoPlayerPort.resolveUri(episode);
// 只补 host,路径与查询串原样保留(签名丢了就等于 403)
expect(uri.path, '/files/episode/11.mp4');
expect(uri.query, 'e=1760000000&s=9f2c');
expect(uri.toString(), endsWith('/files/episode/11.mp4?e=1760000000&s=9f2c'));
});
}
/// 测试用的小环境:仓库 + 假适配器 + 可推进的时钟。
class _TicketEnv {
_TicketEnv({
required this.repository,
required this.adapter,
required this.advance,
});
final HttpPlaybackRepository repository;
final FakeHttpAdapter adapter;
final void Function(Duration) advance;
}
+178
View File
@@ -0,0 +1,178 @@
// Author: fruit.com.cn
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/pages/player/player_logic.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import '../support/fake_watch_history.dart';
import '../support/widget_player.dart';
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
id: id,
title: id,
synopsis: 'synopsis',
posterUrl: '/files/poster.png',
episodes: [
for (final number in numbers)
Episode(
id: '$id-$number',
number: number,
title: 'Episode $number',
videoUrl: 'https://example.test/clip.mp4',
isFree: true,
),
],
);
PlayerLogic _logic(FakeWatchHistory history, {Drama? drama}) {
final logic = PlayerLogic(
history: history,
playback: FakePlaybackRepository(),
pictureInPicture: AndroidPictureInPicture(
channel: const MethodChannel('test/progress/pip'),
),
);
if (drama != null) {
logic.attach(
PlayerPage(drama: drama, initialIndex: 0),
isMounted: () => true,
);
logic.start(
factory: (episode) => WidgetPlayer(),
coordinator: PlaybackCoordinator(),
);
}
return logic;
}
/// 排空 session 串行队列,避免测试结束在 dispose 中途。
Future<void> _drain(PlayerLogic logic) async {
for (var attempt = 0; attempt < 4; attempt++) {
await pumpEventQueue();
await logic.session.playbackSettled;
await logic.session.preloadSettled;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('progress throttle', () {
test('should_report_once_per_interval_when_progress_ticks_keep_coming', () {
final logic = _logic(FakeWatchHistory());
final start = DateTime(2026, 10, 16, 12);
logic.lastProgressWrite = start;
// 契约建议每 10 秒一次:3 秒、9 秒都不报,第 10 秒报一次
expect(logic.progressInterval, const Duration(seconds: 10));
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 3))), isFalse);
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 9))), isFalse);
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 10))), isTrue);
// 报过之后节流窗口重新开始
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 15))), isFalse);
expect(logic.shouldReportProgress(start.add(const Duration(seconds: 20))), isTrue);
logic.dispose();
});
test('should_write_immediately_when_saving_on_episode_switch_or_exit', () async {
final history = FakeWatchHistory();
final drama = _drama('signal');
final logic = _logic(history, drama: drama);
// 切集/退出/切后台走 saveCurrentProgress:不受 10 秒节流限制
await logic.queueProgress(
drama,
drama.episodes[0],
const Duration(seconds: 5),
duration: const Duration(minutes: 3),
);
await logic.queueProgress(
drama,
drama.episodes[1],
const Duration(seconds: 6),
duration: const Duration(minutes: 3),
);
expect(history.writes, [
'signal-1|5000|false',
'signal-2|6000|false',
]);
logic.dispose();
});
test('should_report_finished_when_episode_plays_to_the_end', () async {
final history = FakeWatchHistory();
final drama = _drama('signal');
final logic = _logic(history, drama: drama);
await logic.queueProgress(
drama,
drama.episodes[0],
const Duration(minutes: 3),
duration: const Duration(minutes: 3),
finished: true,
);
expect(history.writes, ['signal-1|180000|true']);
logic.dispose();
});
});
group('resume positioning', () {
test('should_take_position_from_server_when_record_matches_the_episode', () async {
final history = FakeWatchHistory(
)..records['signal'] = const WatchProgress(
dramaId: 'signal',
episodeId: 'signal-2',
position: Duration(seconds: 42),
duration: Duration(minutes: 3),
);
final logic = _logic(history, drama: _drama('signal'));
await _drain(logic);
await logic.loadResumePoint();
expect(logic.resumePositionFor('signal-2'), const Duration(seconds: 42));
// 记录里不是这一集:不给定位(从头播,避免"看第 1 集却跳到第 2 集的位置")
expect(logic.resumePositionFor('signal-1'), isNull);
expect(logic.resumePositionFor('signal-3'), isNull);
logic.dispose();
});
test('should_have_no_resume_position_when_server_read_fails', () async {
final history = FakeWatchHistory()..failReads = true;
final logic = _logic(history, drama: _drama('signal'));
await _drain(logic);
await logic.loadResumePoint();
expect(logic.resumePoint, isNull);
expect(logic.resumeLoaded, isTrue);
logic.dispose();
});
test('should_not_seek_when_position_is_zero', () async {
final history = FakeWatchHistory()
..records['signal'] = const WatchProgress(
dramaId: 'signal',
episodeId: 'signal-1',
position: Duration.zero,
duration: Duration(minutes: 3),
);
final logic = _logic(history, drama: _drama('signal'));
await _drain(logic);
await logic.loadResumePoint();
expect(logic.resumePositionFor('signal-1'), isNull);
logic.dispose();
await _drain(logic);
});
});
}
+93 -96
View File
@@ -1,9 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.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';
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
/// 进度仓库从"本地按 profile+剧+集"换成"服务端每剧一条":
/// 这里钉住续看点换算、空记录、字符串数值解析、失败透传与清理。
void main() {
test('should_clamp_invalid_and_completed_values_when_resolving_resume_position', () {
const duration = Duration(seconds: 8);
@@ -25,106 +27,101 @@ void main() {
);
});
test('should_scope_history_by_profile_drama_and_episode_when_saving', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final history = SharedPreferencesWatchHistoryRepository();
await history.save(
'profile-a',
'drama-a',
'episode-1',
const Duration(seconds: 3),
);
await history.save(
'profile-a',
'drama-a',
'episode-2',
const Duration(seconds: 5),
);
expect(
await history.positionFor('profile-a', 'drama-a', 'episode-1'),
const Duration(seconds: 3),
);
expect(
await history.positionFor('profile-b', 'drama-a', 'episode-1'),
isNull,
);
expect(await history.lastEpisodeId('profile-a', 'drama-a'), 'episode-2');
expect(await history.lastEpisodeId('profile-a', 'drama-b'), isNull);
});
test('should_prefer_first_loaded_episode_when_resuming', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final history = SharedPreferencesWatchHistoryRepository();
await history.save(
'profile-a',
'drama-a',
'episode-1',
const Duration(seconds: 3),
);
await history.save(
'profile-a',
'drama-a',
'episode-2',
const Duration(seconds: 5),
);
final drama = Drama(
id: 'drama-a',
title: 'title',
synopsis: 'synopsis',
posterUrl: '',
episodes: const [
Episode(id: 'episode-1', number: 1, title: 'E1', isFree: true),
Episode(id: 'episode-2', number: 2, title: 'E2', isFree: true),
test('should_return_resume_point_when_server_has_progress', () async {
final history = InMemoryWatchHistoryRepository(
seed: const [
WatchProgress(
dramaId: 'drama-a',
episodeId: 'episode-3',
position: Duration(seconds: 42),
duration: Duration(minutes: 3),
),
],
);
expect(
await history.resumePositionFor('profile-a', drama),
const Duration(seconds: 3),
);
final point = await history.resumePointFor('drama-a');
expect(point?.episodeId, 'episode-3');
expect(point?.position, const Duration(seconds: 42));
});
test('should_fall_back_to_last_watched_episode_when_resuming_a_list_card', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final history = SharedPreferencesWatchHistoryRepository();
await history.save(
'profile-a',
'drama-a',
'episode-2',
const Duration(seconds: 5),
);
test('should_return_no_resume_point_when_server_has_no_record', () async {
final history = InMemoryWatchHistoryRepository();
const card = Drama(
id: 'drama-a',
title: 'title',
synopsis: 'synopsis',
posterUrl: '',
totalEpisodes: 12,
);
expect(
await history.resumePositionFor('profile-a', card),
const Duration(seconds: 5),
);
expect(await history.resumePointFor('drama-a'), isNull);
});
test('should_have_no_resume_position_when_list_card_has_no_history', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final history = SharedPreferencesWatchHistoryRepository();
const card = Drama(
id: 'drama-b',
title: 'title',
synopsis: 'synopsis',
posterUrl: '',
totalEpisodes: 12,
test('should_return_no_resume_point_when_record_has_no_episode', () async {
final history = InMemoryWatchHistoryRepository(
seed: const [
WatchProgress(
dramaId: 'drama-a',
episodeId: '',
position: Duration(seconds: 5),
duration: Duration(minutes: 3),
),
],
);
expect(await history.resumePositionFor('profile-a', card), isNull);
expect(await history.resumePointFor('drama-a'), isNull);
});
test('should_expose_fraction_when_duration_is_known', () {
const record = WatchProgress(
dramaId: 'd',
episodeId: 'e',
position: Duration(seconds: 30),
duration: Duration(seconds: 120),
);
expect(record.fraction, closeTo(0.25, 0.0001));
expect(record.hasPosition, isTrue);
const unknown = WatchProgress(
dramaId: 'd',
episodeId: 'e',
position: Duration(seconds: 30),
duration: Duration.zero,
);
expect(unknown.fraction, 0);
});
test('should_parse_string_numbers_when_server_sends_ids_and_ms_as_strings', () {
final record = WatchProgress.fromJson({
'dramaId': '2111012753262120962',
'episodeId': '2111012753262120963',
'positionMs': '42000',
'durationMs': '180000',
'finished': false,
'playCount': '2',
});
expect(record.dramaId, '2111012753262120962');
expect(record.episodeId, '2111012753262120963');
expect(record.position, const Duration(seconds: 42));
expect(record.duration, const Duration(seconds: 180));
expect(record.playCount, 2);
});
test('should_throw_when_reading_progress_fails', () async {
final history = InMemoryWatchHistoryRepository()..failReads = 1;
expect(() => history.progressFor('drama-a'), throwsStateError);
});
test('should_drop_record_when_removing_progress', () async {
final history = InMemoryWatchHistoryRepository(
seed: const [
WatchProgress(
dramaId: 'drama-a',
episodeId: 'e1',
position: Duration(seconds: 5),
duration: Duration(minutes: 1),
),
],
);
await history.remove('drama-a');
expect(await history.progressFor('drama-a'), isNull);
});
}
+24 -8
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/play_ticket.dart';
import 'package:fruitshorts/core/data/models/player_port.dart';
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
@@ -9,6 +10,7 @@ import 'package:fruitshorts/pages/player/player_logic.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import '../support/fake_watch_history.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
Drama _drama(
String id, {
@@ -35,8 +37,14 @@ Drama _drama(
AndroidPictureInPicture _pip() =>
AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip'));
PlayerLogic _logic(FakeWatchHistory history) =>
PlayerLogic(history: history, pictureInPicture: _pip());
PlayerLogic _logic(
FakeWatchHistory history, {
PlaybackRepository? playback,
}) => PlayerLogic(
history: history,
playback: playback ?? FakePlaybackRepository(),
pictureInPicture: _pip(),
);
/// Drains the session's serialized work so a test never ends mid-disposal.
///
@@ -191,10 +199,7 @@ void main() {
const Duration(seconds: 2),
);
expect(history.writes, [
'demo-local-v1|signal|signal-1|1000',
'demo-local-v1|signal|signal-2|2000',
]);
expect(history.writes, ['signal-1|1000|false', 'signal-2|2000|false']);
});
test('should_keep_later_writes_when_an_earlier_write_fails', () async {
@@ -216,7 +221,7 @@ void main() {
const Duration(seconds: 2),
);
expect(history.writes, ['demo-local-v1|signal|signal-2|2000']);
expect(history.writes, ['signal-2|2000|false']);
});
test('should_write_nothing_when_no_player_is_tracked', () async {
@@ -284,7 +289,18 @@ void main() {
test('should_never_reach_player_factory_when_episode_is_locked', () async {
final factory = _RecordingFactory();
final drama = _drama('locked', isFree: false);
final logic = _logic(FakeWatchHistory());
// 锁定的判定权在后端:票据返回不可播 + 无地址,客户端不得自行推断
final logic = _logic(
FakeWatchHistory(),
playback: FakePlaybackRepository(
ticket: const PlayTicket(
episodeId: 'locked-1',
accessible: false,
accessType: PlayTicket.coinRequired,
unlockCoin: 30,
),
),
);
logic.attach(
PlayerPage(drama: drama, initialIndex: 0),
isMounted: () => true,
-11
View File
@@ -1,6 +1,5 @@
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/models/play_ticket.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
/// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。
@@ -20,16 +19,6 @@ class FakeCatalogRepository implements CatalogRepository {
return dramas.firstWhere((drama) => drama.id == dramaId);
}
@override
Future<PlayTicket> resolvePlay(String episodeId) async {
return PlayTicket(
episodeId: episodeId,
accessible: true,
accessType: PlayTicket.free,
playUrl: 'https://example.test/$episodeId.mp4',
);
}
HomeFeed _feed() => HomeFeed(
sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)],
);
+46 -23
View File
@@ -1,44 +1,67 @@
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
/// Records every history interaction so tests can assert on the key it used.
/// 记录每次进度读取/上报,方便断言"读了哪部剧、上报了哪一集"。
///
/// 服务端模型:每部剧一条续看记录(不再是 profile+剧+集),所以 `reads` 里是 dramaId。
class FakeWatchHistory implements WatchHistoryRepository {
final List<String> reads = [];
final List<String> writes = [];
/// Position returned by [positionFor] when [failReads] is false.
/// 便捷写法:只关心"有续看位置"时直接给这个值(默认落在第 1 集)。
Duration? saved;
/// Last episode returned by [lastEpisodeId].
/// 便捷写法:续看记录落在哪一集(配合 [saved] 使用)。
String? lastEpisode;
/// 精确控制:按剧返回不同记录。
final Map<String, WatchProgress> records = {};
/// [recent] 的返回值。
List<WatchProgress> recentRecords = const [];
bool failReads = false;
bool failWrites = false;
@override
Future<Duration?> positionFor(
String profileId,
String dramaId,
String episodeId,
) async {
reads.add('$profileId|$dramaId|$episodeId');
Future<WatchProgress?> progressFor(String dramaId) async {
reads.add(dramaId);
if (failReads) throw StateError('history unavailable');
return saved;
final record = records[dramaId];
if (record != null) return record;
if (saved == null) return null;
return WatchProgress(
dramaId: dramaId,
episodeId: lastEpisode ?? '$dramaId-1',
position: saved!,
duration: const Duration(minutes: 5),
);
}
@override
Future<String?> lastEpisodeId(String profileId, String dramaId) async {
if (failReads) throw StateError('history unavailable');
return lastEpisode;
}
@override
Future<void> save(
String profileId,
String dramaId,
String episodeId,
Duration position,
) async {
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
}) async {
if (failWrites) throw StateError('history unavailable');
writes.add('$profileId|$dramaId|$episodeId|${position.inMilliseconds}');
writes.add('$episodeId|${position.inMilliseconds}|$finished');
return WatchProgress(
dramaId: '',
episodeId: episodeId,
position: position,
duration: duration ?? Duration.zero,
finished: finished,
);
}
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
if (failReads) throw StateError('history unavailable');
return recentRecords.take(limit).toList();
}
@override
Future<void> remove(String dramaId) async => records.remove(dramaId);
}
+36 -15
View File
@@ -8,6 +8,11 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p
import 'support/fake_catalog.dart';
import 'support/widget_player.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
void main() {
/// 默认按「已登录」起测:账号体系接上后,"登录"不再是本地演示开关,
@@ -27,17 +32,31 @@ void main() {
seedSignedInSession();
});
/// 所有 widget 测试都注入离线仓库:票据 / 进度 / 收藏都不碰网络,
/// 这样断言的是 UI 行为,而不是后端有没有起。
Widget appUnderTest({
PlayerFactory? playerFactory,
CatalogRepository? catalogRepository,
}) => LumaDramaApp(
playerFactory: playerFactory ?? createWidgetPlayer,
catalogRepository: catalogRepository ?? FakeCatalogRepository(),
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(
cards: FakeCatalogRepository().dramas,
),
playbackRepository: FakePlaybackRepository(),
);
testWidgets(
'should_promote_next_without_recreating_it_or_showing_loading_when_home_is_swiped',
(tester) async {
final created = <String>[];
await tester.pumpWidget(
LumaDramaApp(
appUnderTest(
playerFactory: (episode) {
created.add(episode.id);
return WidgetPlayer();
},
catalogRepository: FakeCatalogRepository(),
),
);
await tester.pumpAndSettle();
@@ -64,7 +83,7 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
appUnderTest(),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
@@ -91,7 +110,7 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
appUnderTest(),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
@@ -116,7 +135,7 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
appUnderTest(),
);
await tester.pumpAndSettle();
expect(find.text('Continue Watching'), findsOneWidget);
@@ -171,7 +190,7 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
appUnderTest(),
);
await tester.pumpAndSettle();
expect(find.byTooltip('Captions'), findsNothing);
@@ -200,7 +219,7 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
appUnderTest(),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
@@ -226,7 +245,7 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
appUnderTest(),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my')));
@@ -249,7 +268,7 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
appUnderTest(),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Home'));
@@ -283,7 +302,7 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
appUnderTest(),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Home'));
@@ -335,7 +354,7 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
appUnderTest(),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
@@ -366,7 +385,7 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
appUnderTest(),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
@@ -404,7 +423,7 @@ void main() {
);
await tester.pumpWidget(
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
appUnderTest(),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
@@ -445,7 +464,7 @@ void main() {
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
appUnderTest(),
);
await tester.pumpAndSettle();
@@ -473,8 +492,10 @@ void main() {
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
// 收藏要求登录态:沿用本文件已有的"假登录"初始化
seedSignedInSession();
await tester.pumpWidget(
LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
appUnderTest(),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));