为什么:a) 应用对外名称要统一成 FruitShorts(产品名,nontonki 是原型代号); b) 首发市场是印尼,界面必须有印尼语;c) 归档要求"新增 id-ID 与改名合并生成一次 l10n,别分两次"。 改名: - pubspec name: nontonki → fruitshorts(Dart 包名),description 同步;28 个文件的 package: 导入前缀替换 - Android android:label、iOS CFBundleDisplayName/CFBundleName → FruitShorts - 图标资源 assets/icon/nontonki.png → fruitshorts.png(含 pubspec 两处引用) - README 同步(补印尼语说明,顺带修掉写错的模块名 reelshort-app-api → fruitshorts-app-api) 文案: - 三份 arb 的 appName → FruitShorts、brandMark → FRUITSHORTS、shareFooter → FruitShorts(不留 demo 标记) - 新增 lib/l10n/app_id.arb:174 个值键与 en/zh 完全一致,按印尼语习惯翻译(Coin→Koin、Episode→Episode) - 一次性 flutter gen-l10n 生成 app_localizations_id.dart;id 自动进入 supportedLocales,默认 locale 未改 - 新增 test/config/l10n_id_test.dart:钉住 id 文案、supportedLocales 含 id、品牌文案无 demo 标记、占位符渲染 不动:applicationId / bundle id(lumadrama 遗留,等裁定)、目录名、业务逻辑与页面结构、语言选择页(仍只有 en/zh) 门禁:dart analyze lib test 零告警;flutter test 159 全过(基线 155 + 新增 4);残留扫描 0 命中; 三份 arb 键集一致(174/174/174);真机(iPhone iOS 26.6.1)构建安装启动成功,devicectl 列出显示名 FruitShorts
216 lines
6.5 KiB
Dart
216 lines
6.5 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
|
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
|
import 'package:fruitshorts/core/data/models/player_port.dart';
|
|
|
|
void main() {
|
|
final episodes = List.generate(
|
|
4,
|
|
(i) => Episode(
|
|
id: 'ep-${i + 1}',
|
|
number: i + 1,
|
|
title: 'Episode ${i + 1}',
|
|
videoUrl: 'https://example.test/episode_0${i + 1}.mp4',
|
|
isFree: i < 2,
|
|
),
|
|
);
|
|
|
|
test('should_create_no_player_when_episode_is_locked', () async {
|
|
final factory = FakeFactory();
|
|
final session = PlaybackSession(
|
|
episodes: episodes,
|
|
factory: factory.create,
|
|
canPlay: (episode) => episode.isFree,
|
|
);
|
|
await session.select(2);
|
|
expect(session.isLocked, isTrue);
|
|
expect(factory.created, isEmpty);
|
|
await session.close();
|
|
});
|
|
|
|
test('should_drop_stale_initialize_and_keep_one_player_when_selection_is_rapid', () async {
|
|
final factory = FakeFactory(holdFirstInitialize: true);
|
|
final session = PlaybackSession(
|
|
episodes: episodes,
|
|
factory: factory.create,
|
|
canPlay: (episode) => episode.isFree,
|
|
);
|
|
final first = session.select(0);
|
|
await factory.firstCreated.future;
|
|
final second = session.select(1);
|
|
factory.created.first.releaseInitialize();
|
|
await Future.wait([first, second]);
|
|
expect(factory.peakLive, lessThanOrEqualTo(1));
|
|
expect(factory.created.first.playCalls, 0);
|
|
expect(factory.created.first.disposed, isTrue);
|
|
expect(session.currentIndex, 1);
|
|
expect(factory.created.last.playCalls, 1);
|
|
await session.close();
|
|
expect(factory.live, 0);
|
|
});
|
|
|
|
test(
|
|
'should_pause_without_forgetting_intent_when_backgrounding_or_overlay_shows',
|
|
() async {
|
|
final factory = FakeFactory();
|
|
final session = PlaybackSession(
|
|
episodes: episodes,
|
|
factory: factory.create,
|
|
canPlay: (episode) => episode.isFree,
|
|
);
|
|
await session.select(0);
|
|
final player = factory.created.single;
|
|
expect(player.playCalls, 1);
|
|
session.setAppResumed(false);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(player.pauseCalls, 1);
|
|
session.setAppResumed(true);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(player.playCalls, 2);
|
|
session.setOverlayOpen(true);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(player.pauseCalls, 2);
|
|
session.setUserWantsPlay(false);
|
|
session.setOverlayOpen(false);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(player.playCalls, 2);
|
|
await session.close();
|
|
},
|
|
);
|
|
|
|
test('should_finish_resume_preparation_before_video_starts_when_resuming', () async {
|
|
final factory = FakeFactory();
|
|
final prepared = Completer<void>();
|
|
final session = PlaybackSession(
|
|
episodes: episodes,
|
|
factory: factory.create,
|
|
canPlay: (episode) => episode.isFree,
|
|
prepare: (_, _) => prepared.future,
|
|
);
|
|
final selection = session.select(0);
|
|
await factory.firstCreated.future;
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(factory.created.single.playCalls, 0);
|
|
prepared.complete();
|
|
await selection;
|
|
expect(factory.created.single.playCalls, 1);
|
|
await session.close();
|
|
});
|
|
|
|
test('should_resume_playback_when_swiping_after_pause', () async {
|
|
final factory = FakeFactory();
|
|
final session = PlaybackSession(
|
|
episodes: episodes,
|
|
factory: factory.create,
|
|
canPlay: (episode) => episode.isFree,
|
|
);
|
|
await session.select(0);
|
|
session.setUserWantsPlay(false);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(session.userWantsPlay, isFalse);
|
|
|
|
await session.select(1);
|
|
|
|
expect(session.userWantsPlay, isTrue);
|
|
await Future<void>.delayed(Duration.zero);
|
|
final playing = session.active;
|
|
expect(playing, isA<FakePlayer>());
|
|
expect((playing! as FakePlayer).playCalls, greaterThan(0));
|
|
await session.close();
|
|
});
|
|
|
|
test('should_keep_user_pause_when_reselecting_the_same_episode', () async {
|
|
final factory = FakeFactory();
|
|
final session = PlaybackSession(
|
|
episodes: episodes,
|
|
factory: factory.create,
|
|
canPlay: (episode) => episode.isFree,
|
|
);
|
|
await session.select(0);
|
|
session.setUserWantsPlay(false);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
// 页面重新可见等场景会重复选中同一集,这时不能把暂停改成播放。
|
|
await session.select(0);
|
|
|
|
expect(session.userWantsPlay, isFalse);
|
|
expect(session.canAutoAdvance, isFalse);
|
|
await session.close();
|
|
});
|
|
|
|
test('should_respect_visibility_and_intent_when_advancing_automatically', () async {
|
|
final factory = FakeFactory();
|
|
final session = PlaybackSession(
|
|
episodes: episodes,
|
|
factory: factory.create,
|
|
canPlay: (episode) => episode.isFree,
|
|
);
|
|
await session.select(0);
|
|
expect(session.canAutoAdvance, isTrue);
|
|
session.setRouteVisible(false);
|
|
expect(session.canAutoAdvance, isFalse);
|
|
session.setRouteVisible(true);
|
|
session.setOverlayOpen(true);
|
|
expect(session.canAutoAdvance, isFalse);
|
|
session.setOverlayOpen(false);
|
|
session.setUserWantsPlay(false);
|
|
expect(session.canAutoAdvance, isFalse);
|
|
await session.close();
|
|
});
|
|
}
|
|
|
|
class FakeFactory {
|
|
FakeFactory({this.holdFirstInitialize = false});
|
|
final bool holdFirstInitialize;
|
|
final List<FakePlayer> created = [];
|
|
final Completer<void> firstCreated = Completer<void>();
|
|
int live = 0;
|
|
int peakLive = 0;
|
|
|
|
PlayerPort create(Episode episode) {
|
|
live++;
|
|
if (live > peakLive) peakLive = live;
|
|
final player = FakePlayer(
|
|
holdInitialize: holdFirstInitialize && created.isEmpty,
|
|
onDispose: () => live--,
|
|
);
|
|
created.add(player);
|
|
if (!firstCreated.isCompleted) firstCreated.complete();
|
|
return player;
|
|
}
|
|
}
|
|
|
|
class FakePlayer implements PlayerPort {
|
|
FakePlayer({required this.holdInitialize, required this.onDispose});
|
|
final bool holdInitialize;
|
|
final void Function() onDispose;
|
|
final Completer<void> _initializeGate = Completer<void>();
|
|
bool initialized = false;
|
|
bool disposed = false;
|
|
int playCalls = 0;
|
|
int pauseCalls = 0;
|
|
|
|
void releaseInitialize() => _initializeGate.complete();
|
|
|
|
@override
|
|
Future<void> initialize() async {
|
|
if (holdInitialize) await _initializeGate.future;
|
|
initialized = true;
|
|
}
|
|
|
|
@override
|
|
Future<void> play() async => playCalls++;
|
|
|
|
@override
|
|
Future<void> pause() async => pauseCalls++;
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
onDispose();
|
|
}
|
|
}
|