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:
1 parent
94ff9f1177
commit
d7eff4ca59
48 files changed
+2411
-377
No files matched your search
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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/'));
|
||||
});
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
|
||||
@@ -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
@@ -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')));
|
||||
|
||||
Reference in new issue
Block a user