提交 Nontonki 短剧原型,准备推送到远程仓库。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
b1f6ce4bf9
148 files changed
+11175
No files matched your search
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/data/services/catalog_controller.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'empty catalog is distinct from a failed load, and retry recovers',
|
||||
() async {
|
||||
final repo = ScriptedCatalogRepository();
|
||||
final controller = CatalogController(repo);
|
||||
await controller.load();
|
||||
expect(controller.dramas, isEmpty);
|
||||
expect(controller.error, isNull);
|
||||
|
||||
repo.fail = true;
|
||||
await controller.load();
|
||||
expect(controller.error, isNotNull);
|
||||
|
||||
repo.fail = false;
|
||||
repo.items = await DemoCatalogRepository().fetchDramas();
|
||||
await controller.load();
|
||||
expect(controller.error, isNull);
|
||||
expect(controller.dramas, hasLength(3));
|
||||
expect(controller.dramas.first.episodes.length, 3);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class ScriptedCatalogRepository implements CatalogRepository {
|
||||
bool fail = false;
|
||||
List<Drama> items = const [];
|
||||
|
||||
@override
|
||||
Future<List<Drama>> fetchDramas() async {
|
||||
if (fail) throw StateError('offline');
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('all mock entries reuse one bundled local demo video', () async {
|
||||
final dramas = await DemoCatalogRepository().fetchDramas();
|
||||
expect(dramas, hasLength(3));
|
||||
expect(dramas.every((drama) => drama.episodes.length == 3), isTrue);
|
||||
final paths = {
|
||||
for (final drama in dramas)
|
||||
for (final episode in drama.episodes) episode.assetPath,
|
||||
};
|
||||
expect(paths, {'assets/media/demo_clip.mp4'});
|
||||
expect(
|
||||
dramas.every(
|
||||
(drama) => drama.episodes.every((episode) => episode.isFree),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
final bytes = await rootBundle.load(paths.single);
|
||||
expect(bytes.lengthInBytes, greaterThan(1000000));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/pages/home/home_feed_sections.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'home categories filter demo dramas without changing their media',
|
||||
() async {
|
||||
final dramas = await DemoCatalogRepository().fetchDramas();
|
||||
final sections = HomeFeedSections(dramas);
|
||||
|
||||
expect(sections.indicesFor(HomeFeedCategory.recommended), [0, 1, 2]);
|
||||
expect(sections.indicesFor(HomeFeedCategory.animated), [0]);
|
||||
expect(sections.indicesFor(HomeFeedCategory.liveAction), [1, 2]);
|
||||
expect(sections.indicesFor(HomeFeedCategory.following), isEmpty);
|
||||
expect(
|
||||
sections.dramaAt(HomeFeedCategory.liveAction, 0).title,
|
||||
'City Lights & Velvet Nights',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/services/engagement_controller.dart';
|
||||
import 'package:nontonki/core/data/repositories/engagement_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';
|
||||
|
||||
void main() {
|
||||
test('saved, liked and comments are kept by drama id', () async {
|
||||
final repository = InMemoryEngagementRepository();
|
||||
final first = EngagementController(repository);
|
||||
await first.load();
|
||||
await first.toggleSaved('signal');
|
||||
await first.toggleLiked('signal');
|
||||
await first.addComment('signal', ' Great clip ');
|
||||
|
||||
expect(first.isSaved('signal'), isTrue);
|
||||
expect(first.isSaved('city'), isFalse);
|
||||
expect(first.isLiked('city'), isFalse);
|
||||
expect(first.commentsFor('signal'), ['Great clip']);
|
||||
expect(first.commentsFor('city'), isEmpty);
|
||||
|
||||
final reopened = EngagementController(repository);
|
||||
await reopened.load();
|
||||
expect(reopened.isSaved('signal'), isTrue);
|
||||
expect(reopened.isLiked('signal'), isTrue);
|
||||
expect(reopened.commentsFor('signal'), ['Great clip']);
|
||||
await reopened.toggleSaved('signal');
|
||||
expect(reopened.isSaved('signal'), isFalse);
|
||||
});
|
||||
|
||||
test('local engagement survives a new repository instance', () async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final first = EngagementController(SharedPreferencesEngagementRepository());
|
||||
await first.load();
|
||||
await first.toggleSaved('city');
|
||||
await first.addComment('city', 'Local note');
|
||||
|
||||
final reopened = EngagementController(
|
||||
SharedPreferencesEngagementRepository(),
|
||||
);
|
||||
await reopened.load();
|
||||
expect(reopened.isSaved('city'), isTrue);
|
||||
expect(reopened.commentsFor('city'), ['Local note']);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/data/services/playback_session.dart';
|
||||
import 'package:nontonki/core/data/models/player_port.dart';
|
||||
|
||||
void main() {
|
||||
final episodes = List.generate(
|
||||
5,
|
||||
(i) => Episode(
|
||||
id: 'ep-$i',
|
||||
number: i + 1,
|
||||
title: 'Episode $i',
|
||||
assetPath: 'test-$i.mp4',
|
||||
isFree: i < 4,
|
||||
),
|
||||
);
|
||||
|
||||
test('warm playback starts before old native disposal finishes', () async {
|
||||
final release = Completer<void>();
|
||||
final factory = _Factory(disposeGates: {'ep-0': release});
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (_) => true,
|
||||
);
|
||||
await session.select(0, preloadIndex: 1);
|
||||
await session.preloadSettled;
|
||||
final selection = session.select(1, preloadIndex: 2);
|
||||
await _tick();
|
||||
expect(factory.players[1].playing, isTrue);
|
||||
expect(factory.players[0].playing, isFalse);
|
||||
expect(session.loading, isFalse);
|
||||
expect(session.livePlayerCount, 2);
|
||||
expect(factory.players, hasLength(2));
|
||||
release.complete();
|
||||
await selection;
|
||||
await session.preloadSettled;
|
||||
expect(factory.peakLive, 2);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('failed native release blocks another route from allocating', () async {
|
||||
final factory = _Factory(disposeFailures: {'ep-0'});
|
||||
final coordinator = PlaybackCoordinator();
|
||||
PlaybackSession createSession() => PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (_) => true,
|
||||
coordinator: coordinator,
|
||||
);
|
||||
final home = createSession();
|
||||
final series = createSession();
|
||||
await home.select(0, preloadIndex: 1);
|
||||
await home.preloadSettled;
|
||||
home.setRouteVisible(false);
|
||||
await series.select(2, preloadIndex: 3);
|
||||
expect(series.error, isNotNull);
|
||||
expect(factory.players, hasLength(2));
|
||||
await series.select(2, preloadIndex: 3);
|
||||
await series.preloadSettled;
|
||||
expect(series.error, isNull);
|
||||
expect(factory.peakLive, 2);
|
||||
await series.close();
|
||||
await home.close();
|
||||
expect(factory.live, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'next is prepared silently and promoted without another initialize',
|
||||
() async {
|
||||
final factory = _Factory();
|
||||
final prepared = <String>[];
|
||||
final activated = <String>[];
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (e) => e.isFree,
|
||||
prepare: (e, _) async => prepared.add(e.id),
|
||||
activate: (e, _) async => activated.add(e.id),
|
||||
);
|
||||
await session.select(0, preloadIndex: 1);
|
||||
await session.preloadSettled;
|
||||
final warm = factory.players[1];
|
||||
expect(prepared, ['ep-0', 'ep-1']);
|
||||
expect(activated, ['ep-0']);
|
||||
expect(warm.playCalls, 0);
|
||||
expect(session.preloadedIndex, 1);
|
||||
final loadingStates = <bool>[];
|
||||
session.addListener(() => loadingStates.add(session.loading));
|
||||
await session.select(1, preloadIndex: 2);
|
||||
await session.preloadSettled;
|
||||
expect(session.active, same(warm));
|
||||
expect(warm.initializeCalls, 1);
|
||||
expect(warm.playCalls, 1);
|
||||
expect(loadingStates, isNot(contains(true)));
|
||||
expect(activated, ['ep-0', 'ep-1']);
|
||||
expect(factory.peakLive, 2);
|
||||
expect(factory.peakPlaying, 1);
|
||||
await session.close();
|
||||
expect(factory.live, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test('in-flight preload is reused when swiped before ready', () async {
|
||||
final gate = Completer<void>();
|
||||
final factory = _Factory(gates: {'ep-1': gate});
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (_) => true,
|
||||
);
|
||||
await session.select(0, preloadIndex: 1);
|
||||
await _tick();
|
||||
final warm = factory.players[1];
|
||||
final selection = session.select(1, preloadIndex: 2);
|
||||
await _tick();
|
||||
expect(session.loading, isTrue);
|
||||
gate.complete();
|
||||
await selection;
|
||||
await session.preloadSettled;
|
||||
expect(session.active, same(warm));
|
||||
expect(warm.initializeCalls, 1);
|
||||
expect(factory.peakLive, lessThanOrEqualTo(2));
|
||||
expect(factory.peakPlaying, 1);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('rapid reverse and skipped selection discard stale warm work', () async {
|
||||
final gate = Completer<void>();
|
||||
final factory = _Factory(gates: {'ep-1': gate});
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (_) => true,
|
||||
);
|
||||
await session.select(0, preloadIndex: 1);
|
||||
await _tick();
|
||||
final stale = factory.players[1];
|
||||
final skip = session.select(3, preloadIndex: 4);
|
||||
final reverse = session.select(0, preloadIndex: 2);
|
||||
gate.complete();
|
||||
await Future.wait([skip, reverse]);
|
||||
await session.preloadSettled;
|
||||
expect(session.currentIndex, 0);
|
||||
expect(session.preloadedIndex, 2);
|
||||
expect(stale.playCalls, 0);
|
||||
expect(stale.disposed, isTrue);
|
||||
expect(factory.peakLive, lessThanOrEqualTo(2));
|
||||
expect(factory.peakPlaying, 1);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'category adjacency and locked next never preload the wrong episode',
|
||||
() async {
|
||||
final factory = _Factory();
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (e) => e.isFree,
|
||||
);
|
||||
await session.select(0, preloadIndex: 2);
|
||||
await session.preloadSettled;
|
||||
expect(factory.players.map((p) => p.id), ['ep-0', 'ep-2']);
|
||||
await session.select(0, preloadIndex: 4);
|
||||
await session.preloadSettled;
|
||||
expect(session.preloadedIndex, isNull);
|
||||
expect(factory.players.any((p) => p.id == 'ep-4'), isFalse);
|
||||
await session.close();
|
||||
},
|
||||
);
|
||||
|
||||
test('preload failure is isolated and selecting it retries once', () async {
|
||||
final factory = _Factory(failOnce: {'ep-1'});
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (_) => true,
|
||||
);
|
||||
await session.select(0, preloadIndex: 1);
|
||||
await session.preloadSettled;
|
||||
expect(session.error, isNull);
|
||||
expect(session.loading, isFalse);
|
||||
expect(factory.players.first.playing, isTrue);
|
||||
await session.select(1);
|
||||
expect(session.error, isNull);
|
||||
expect(factory.players.where((p) => p.id == 'ep-1'), hasLength(2));
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('completion in background never starts either player', () async {
|
||||
final gate = Completer<void>();
|
||||
final factory = _Factory(gates: {'ep-1': gate});
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (_) => true,
|
||||
);
|
||||
await session.select(0, preloadIndex: 1);
|
||||
await _tick();
|
||||
final selection = session.select(1, preloadIndex: 2);
|
||||
await _tick();
|
||||
session.setAppResumed(false);
|
||||
gate.complete();
|
||||
await selection;
|
||||
await session.preloadSettled;
|
||||
expect(factory.players.every((p) => !p.playing), isTrue);
|
||||
expect(factory.players.any((p) => p.id == 'ep-2'), isFalse);
|
||||
session.setUserWantsPlay(false);
|
||||
session.setAppResumed(true);
|
||||
await session.playbackSettled;
|
||||
expect(factory.players.every((p) => !p.playing), isTrue);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('closing during preloading disposes pending resources', () async {
|
||||
final gate = Completer<void>();
|
||||
final factory = _Factory(gates: {'ep-1': gate});
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (_) => true,
|
||||
);
|
||||
await session.select(0, preloadIndex: 1);
|
||||
await _tick();
|
||||
final closed = session.close();
|
||||
gate.complete();
|
||||
await closed;
|
||||
expect(factory.live, 0);
|
||||
expect(factory.players.last.playCalls, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'home and series share a two-player budget across route handoff',
|
||||
() async {
|
||||
final factory = _Factory();
|
||||
final coordinator = PlaybackCoordinator();
|
||||
PlaybackSession createSession() => PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (_) => true,
|
||||
coordinator: coordinator,
|
||||
);
|
||||
final home = createSession();
|
||||
final series = createSession();
|
||||
await home.select(0, preloadIndex: 1);
|
||||
await home.preloadSettled;
|
||||
home.setRouteVisible(false);
|
||||
await series.select(2, preloadIndex: 3);
|
||||
await series.preloadSettled;
|
||||
expect(home.livePlayerCount, 0);
|
||||
expect(factory.peakLive, 2);
|
||||
expect(factory.peakPlaying, 1);
|
||||
await series.close();
|
||||
home.setRouteVisible(true);
|
||||
await _tick();
|
||||
await home.playbackSettled;
|
||||
await home.preloadSettled;
|
||||
expect(home.active, isNotNull);
|
||||
expect(factory.peakLive, 2);
|
||||
await home.close();
|
||||
expect(factory.live, 0);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _tick() => Future<void>.delayed(Duration.zero);
|
||||
|
||||
class _Factory {
|
||||
_Factory({
|
||||
this.gates = const {},
|
||||
this.disposeGates = const {},
|
||||
Set<String>? failOnce,
|
||||
Set<String>? disposeFailures,
|
||||
}) : failures = failOnce ?? {},
|
||||
disposeFailures = disposeFailures ?? {};
|
||||
final Map<String, Completer<void>> gates;
|
||||
final Map<String, Completer<void>> disposeGates;
|
||||
final Set<String> disposeFailures;
|
||||
final Set<String> failures;
|
||||
final List<_Player> players = [];
|
||||
int live = 0;
|
||||
int peakLive = 0;
|
||||
int peakPlaying = 0;
|
||||
|
||||
PlayerPort create(Episode episode) {
|
||||
live++;
|
||||
if (live > peakLive) peakLive = live;
|
||||
final player = _Player(
|
||||
episode.id,
|
||||
this,
|
||||
gates[episode.id],
|
||||
failures.remove(episode.id),
|
||||
);
|
||||
players.add(player);
|
||||
return player;
|
||||
}
|
||||
}
|
||||
|
||||
class _Player implements PlayerPort {
|
||||
_Player(this.id, this.factory, this.gate, this.fail);
|
||||
final String id;
|
||||
final _Factory factory;
|
||||
final Completer<void>? gate;
|
||||
final bool fail;
|
||||
bool playing = false;
|
||||
bool disposed = false;
|
||||
int initializeCalls = 0;
|
||||
int playCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
initializeCalls++;
|
||||
await gate?.future;
|
||||
if (fail) throw StateError('Test initialization failed');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
expect(disposed, isFalse);
|
||||
playCalls++;
|
||||
playing = true;
|
||||
final count = factory.players.where((p) => p.playing).length;
|
||||
if (count > factory.peakPlaying) factory.peakPlaying = count;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async => playing = false;
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (disposed) return;
|
||||
await gate?.future;
|
||||
await factory.disposeGates[id]?.future;
|
||||
if (factory.disposeFailures.remove(id)) {
|
||||
throw StateError('Test disposal failed');
|
||||
}
|
||||
disposed = true;
|
||||
playing = false;
|
||||
factory.live--;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:nontonki/core/data/services/playback_queue.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'home feed advances between dramas, series player between episodes',
|
||||
() async {
|
||||
final dramas = await DemoCatalogRepository().fetchDramas();
|
||||
final home = PlaybackQueue.home(dramas);
|
||||
expect(home.length, 3);
|
||||
expect(home.entryAt(0).drama.id, dramas[0].id);
|
||||
expect(home.entryAt(1).drama.id, dramas[1].id);
|
||||
expect(home.entryAt(1).episode.number, 1);
|
||||
expect(home.nextIndex(0), 1);
|
||||
expect(home.nextIndex(2), isNull);
|
||||
|
||||
final series = PlaybackQueue.series(dramas[0]);
|
||||
expect(series.length, 3);
|
||||
expect(series.entryAt(0).drama.id, series.entryAt(1).drama.id);
|
||||
expect(series.entryAt(1).episode.number, 2);
|
||||
expect(series.nextIndex(0), 1);
|
||||
expect(series.nextIndex(2), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/data/services/playback_session.dart';
|
||||
import 'package:nontonki/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}',
|
||||
assetPath: 'assets/media/episode_0${i + 1}.mp4',
|
||||
isFree: i < 2,
|
||||
),
|
||||
);
|
||||
|
||||
test('locked episode never creates or initializes a player', () 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('rapid selection drops stale initialize and keeps one player', () 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(
|
||||
'background and overlay pause without forgetting playback intent',
|
||||
() 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('resume preparation finishes before video starts', () 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('automatic advance respects visibility and playback intent', () 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/pages/player/widgets/seek_progress_bar.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
void main() {
|
||||
test('seek target stays inside playable duration', () {
|
||||
const duration = Duration(seconds: 12);
|
||||
expect(seekPositionForFraction(0, duration), Duration.zero);
|
||||
expect(
|
||||
seekPositionForFraction(0.5, duration),
|
||||
greaterThan(const Duration(seconds: 5)),
|
||||
);
|
||||
expect(seekPositionForFraction(1, duration), lessThan(duration));
|
||||
});
|
||||
|
||||
testWidgets('horizontal slider drag previews and seeks on release', (
|
||||
tester,
|
||||
) async {
|
||||
final video = ValueNotifier<VideoPlayerValue>(
|
||||
const VideoPlayerValue(
|
||||
duration: Duration(seconds: 12),
|
||||
position: Duration(seconds: 3),
|
||||
isInitialized: true,
|
||||
),
|
||||
);
|
||||
addTearDown(video.dispose);
|
||||
var started = 0;
|
||||
Duration? target;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 300,
|
||||
child: SeekProgressBar(
|
||||
valueListenable: video,
|
||||
semanticsLabel: 'Seek video',
|
||||
onScrubStart: () => started++,
|
||||
onSeek: (position) async {
|
||||
target = position;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(Slider)),
|
||||
);
|
||||
await gesture.moveBy(const Offset(120, 0));
|
||||
await tester.pump();
|
||||
expect(started, 1);
|
||||
expect(target, isNull);
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(target, isNotNull);
|
||||
expect(target!, greaterThan(const Duration(seconds: 3)));
|
||||
expect(target!, lessThan(const Duration(seconds: 12)));
|
||||
});
|
||||
|
||||
testWidgets('compact seek bar shows time only while dragging', (
|
||||
tester,
|
||||
) async {
|
||||
final video = ValueNotifier<VideoPlayerValue>(
|
||||
const VideoPlayerValue(
|
||||
duration: Duration(seconds: 12),
|
||||
position: Duration(seconds: 3),
|
||||
isInitialized: true,
|
||||
),
|
||||
);
|
||||
addTearDown(video.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SeekProgressBar(
|
||||
valueListenable: video,
|
||||
semanticsLabel: 'Seek video',
|
||||
compactIdle: true,
|
||||
onSeek: (_) async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('00:03'), findsNothing);
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(Slider)),
|
||||
);
|
||||
await gesture.moveBy(const Offset(40, 0));
|
||||
await tester.pump();
|
||||
expect(find.text('00:12'), findsOneWidget);
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('00:12'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/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';
|
||||
|
||||
void main() {
|
||||
test('resume position clamps invalid and completed values', () {
|
||||
const duration = Duration(seconds: 8);
|
||||
expect(
|
||||
clampResumePosition(const Duration(seconds: -1), duration),
|
||||
Duration.zero,
|
||||
);
|
||||
expect(
|
||||
clampResumePosition(const Duration(seconds: 3), duration),
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
expect(
|
||||
clampResumePosition(const Duration(seconds: 9), duration),
|
||||
Duration.zero,
|
||||
);
|
||||
expect(
|
||||
clampResumePosition(const Duration(milliseconds: 7700), duration),
|
||||
Duration.zero,
|
||||
);
|
||||
});
|
||||
|
||||
test('watch history is scoped by profile, drama and episode', () 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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/data/models/player_port.dart';
|
||||
|
||||
// Widget tests exercise navigation and scheduling without a native decoder.
|
||||
PlayerPort createWidgetPlayer(Episode episode) => WidgetPlayer();
|
||||
|
||||
class WidgetPlayer implements PlayerPort {
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<void> play() async {}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/app.dart';
|
||||
import 'package:nontonki/pages/player/player_view.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import 'support/widget_player.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'home swipe promotes next without recreating it or showing loading',
|
||||
(tester) async {
|
||||
final created = <String>[];
|
||||
await tester.pumpWidget(
|
||||
LumaDramaApp(
|
||||
playerFactory: (episode) {
|
||||
created.add(episode.id);
|
||||
return WidgetPlayer();
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(created, hasLength(2));
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
||||
await tester.pump();
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||
expect(created, hasLength(3));
|
||||
expect(created.toSet(), hasLength(3));
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('immersive home opens the series detail', (tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
expect(find.byType(Slider), findsOneWidget);
|
||||
expect(find.text('Home'), findsWidgets);
|
||||
expect(find.text('Recommended'), findsOneWidget);
|
||||
expect(find.textContaining('Playing now'), findsOneWidget);
|
||||
expect(find.byTooltip('Episodes'), findsWidgets);
|
||||
expect(find.byTooltip('Watch in landscape'), findsOneWidget);
|
||||
expect(find.byTooltip('Picture in picture'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Explore series'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Watch episode 1'), findsOneWidget);
|
||||
expect(find.text('Episodes'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('landscape control hides tabs and restores them on exit', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Watch in landscape'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-tabs-bar')), findsNothing);
|
||||
expect(find.byTooltip('Exit fullscreen'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Exit fullscreen'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('home quick drawer opens current series and switches root tabs', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(360, 720);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Open quick menu'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<PlayerPage>(find.byType(PlayerPage)).active, isFalse);
|
||||
expect(find.text('Quick access'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(Drawer),
|
||||
matching: find.text('Same demo clip · free'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Watch episode 1'), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<PlayerPage>(find.byType(PlayerPage)).active, isTrue);
|
||||
|
||||
await tester.tap(find.byTooltip('Open quick menu'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('drawer-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Trending Micro-Dramas'), findsOneWidget);
|
||||
expect(
|
||||
Navigator.of(tester.element(find.text('Trending Micro-Dramas'))).canPop(),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('interface language changes without a caption control', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Captions'), findsNothing);
|
||||
await tester.tap(find.byTooltip('My'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Continue Watching'), findsOneWidget);
|
||||
expect(find.text('History'), findsOneWidget);
|
||||
expect(find.text('Watchlist'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('App Language'),
|
||||
120,
|
||||
scrollable: find
|
||||
.descendant(
|
||||
of: find.byType(ListView),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(find.text('PREFERENCES'), findsOneWidget);
|
||||
await tester.tap(find.text('App Language'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('简体中文').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('我的'), findsWidgets);
|
||||
expect(find.text('继续观看'), findsOneWidget);
|
||||
expect(find.text('找剧'), findsOneWidget);
|
||||
await tester.tap(find.text('找剧'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('热门短剧'), findsOneWidget);
|
||||
expect(find.text('排行榜'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('home controls fit a compact Android viewport', (tester) async {
|
||||
tester.view.physicalSize = const Size(360, 720);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Recommended'), findsOneWidget);
|
||||
await tester.drag(find.byType(PageView), const Offset(0, -500));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Explore series'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Watch episode 1'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(Slider), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('My page fits a compact Android viewport and returns home', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(360, 720);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('My'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Continue Watching'), findsOneWidget);
|
||||
expect(find.text('History'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.text('Home').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Recommended'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Discover shows three mock playable dramas and one pending poster',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(360, 720);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Discover'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Trending Micro-Dramas'), findsOneWidget);
|
||||
expect(find.text('Filter'), findsOneWidget);
|
||||
expect(find.text('Top Charts'), findsOneWidget);
|
||||
expect(find.text('New Releases'), findsOneWidget);
|
||||
expect(find.text('Watchlist'), findsOneWidget);
|
||||
expect(find.text('Same demo clip · free'), findsNWidgets(3));
|
||||
expect(find.text('Video pending'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Vengeance');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Vengeance Rising'), findsOneWidget);
|
||||
expect(find.text('Same demo clip · free'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Watchlist'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No shows here yet.'), findsOneWidget);
|
||||
await tester.tap(find.text('Home').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Recommended'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('bottom tabs stay on one route and preserve Discover search', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Discover'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), 'Vengeance');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Vengeance Rising'), findsOneWidget);
|
||||
expect(
|
||||
Navigator.of(tester.element(find.byType(TextField))).canPop(),
|
||||
isFalse,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('History'), findsOneWidget);
|
||||
expect(
|
||||
Navigator.of(tester.element(find.text('History'))).canPop(),
|
||||
isFalse,
|
||||
);
|
||||
await tester.tap(find.text('Watchlist'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No shows here yet.'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(TextField, 'Vengeance'), findsOneWidget);
|
||||
expect(find.text('Vengeance Rising'), findsOneWidget);
|
||||
expect(
|
||||
Navigator.of(tester.element(find.byType(TextField))).canPop(),
|
||||
isFalse,
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No shows here yet.'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Recommended'), findsOneWidget);
|
||||
expect(
|
||||
Navigator.of(tester.element(find.text('Recommended'))).canPop(),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'home swipe changes drama, entered player swipe changes episode',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
|
||||
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||
expect(find.textContaining('Episode 1'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Explore series'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||
await tester.tap(find.text('Watch episode 1'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(Slider), findsOneWidget);
|
||||
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('EPISODE 2 / 3'), findsOneWidget);
|
||||
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('home categories switch by video, top row and tap', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byIcon(Icons.tune_rounded), findsNothing);
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
|
||||
await tester.drag(
|
||||
find.byKey(const Key('home-video-swipe')),
|
||||
const Offset(220, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-category-liveAction')), findsOneWidget);
|
||||
|
||||
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Vengeance Rising'), findsOneWidget);
|
||||
|
||||
await tester.drag(
|
||||
find.byKey(const Key('home-category-swipe')),
|
||||
const Offset(190, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
|
||||
await tester.drag(
|
||||
find.byKey(const Key('home-category-swipe')),
|
||||
const Offset(-190, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('home-category-following')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No dramas in this category yet.'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('home-category-recommended')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
testWidgets('right rail saves, comments, likes and invokes share in order', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
String? sharedText;
|
||||
const channel = MethodChannel('com.stormg.lumadrama/share');
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
|
||||
call,
|
||||
) async {
|
||||
sharedText = (call.arguments as Map)['text'] as String;
|
||||
return null;
|
||||
});
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
channel,
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final keys = [
|
||||
const Key('rail-save'),
|
||||
const Key('rail-comments'),
|
||||
const Key('rail-like'),
|
||||
const Key('rail-share'),
|
||||
];
|
||||
final tops = keys
|
||||
.map((key) => tester.getTopLeft(find.byKey(key)).dy)
|
||||
.toList();
|
||||
expect(tops, orderedEquals(tops.toList()..sort()));
|
||||
|
||||
await tester.tap(find.byKey(keys[0]));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(keys[1]));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Only visible on this device'), findsOneWidget);
|
||||
await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo');
|
||||
await tester.tap(find.byKey(const Key('comment-post')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Nice demo'), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(keys[2]));
|
||||
await tester.tap(find.byKey(keys[3]));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sharedText, contains('The Signal'));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'right swipe from leftmost category opens drawer and root stays',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await tester.drag(
|
||||
find.byKey(const Key('home-category-swipe')),
|
||||
const Offset(200, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
expect(find.text('No dramas in this category yet.'), findsOneWidget);
|
||||
await tester.drag(
|
||||
find.byKey(const Key('home-empty-swipe')),
|
||||
const Offset(200, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Quick access'), findsOneWidget);
|
||||
await tester.drag(find.byType(Drawer), const Offset(150, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Quick access'), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('system back steps categories then opens drawer without exit', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No dramas in this category yet.'), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Quick access'), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('My watchlist lists saved dramas across the home feed', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
const LumaDramaApp(playerFactory: createWidgetPlayer),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('rail-save')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('rail-save')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Watchlist'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||
await tester.tap(find.text('The Signal'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Watch episode 1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user