Implements VOD (past broadcast) browsing and chat replay that reuses the existing live chat rendering pipeline in read-only mode. Models & API: - video.dart: Helix video model with duration parsing (3h26m15s → Duration) - gql_chat_comment.dart: GQL VOD chat comment models (manual parsing) - IRCMessage.fromGqlComment(): adapter mapping GQL comments → IRC format so the existing chat rendering pipeline (generateSpan, badges, emotes) works unchanged for VOD chat - TwitchApi.getVideos(): Helix endpoint for user VOD list with pagination - TwitchGqlApi.getVideoComments(): GQL persisted query using mobile client ID (bypasses Client-Integrity) for VOD chat comments Architecture: - ChatRenderContext interface: minimal abstraction over ChatStore so ChatMessage widget works with both live ChatStore and VodChatStore - ChatStore now implements ChatRenderContext (one-line change) - ChatMessage accepts ChatRenderContext instead of ChatStore, with type guards for live-only actions (reply, paste, name tap navigation) VOD stores: - VodChatStore: MobX store implementing ChatRenderContext with identical message batching (200ms timer, 5000 limit, 100 render limit, 20% trim), scroll/autoscroll, playback position tracking, seek detection (delta < -5 or > 10 triggers clear + re-fetch), comment buffer with pre-fetching when < 50 comments remain - VodListStore: standard paginated list store for VOD browsing VOD UI: - VodPlayer: matches live channel layout exactly (no AppBar, SafeArea, 16:9 WebView + expanded chat) with JS position polling every 1s + seeked event listener via VodPosition JS channel - VodList: paginated VOD list with scroll-based loading - VodCard: thumbnail card with duration overlay and relative dates - Entry point: "Past broadcasts" in chat details drawer Tests (21 new): - video_test.dart: VideoTwitch/VideosTwitch parsing, duration parsing - gql_chat_comment_test.dart: comment parsing, response error handling, IRCMessage.fromGqlComment adapter (tags, localEmotes, deleted user) - twitch_gql_api_test.dart: getVideoComments mock tests - twitch_api_test.dart: getVideos mock tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
208 lines
6 KiB
Dart
208 lines
6 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
|
import 'package:frosty/constants.dart';
|
|
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
|
|
|
void main() {
|
|
late Dio dio;
|
|
late DioAdapter dioAdapter;
|
|
late TwitchGqlApi api;
|
|
|
|
setUp(() {
|
|
dio = Dio(BaseOptions());
|
|
dioAdapter = DioAdapter(dio: dio);
|
|
api = TwitchGqlApi(dio);
|
|
});
|
|
|
|
group('getVideoComments', () {
|
|
test('fetches comments by content offset', () async {
|
|
dioAdapter.onPost(
|
|
'https://gql.twitch.tv/gql',
|
|
(server) => server.reply(200, {
|
|
'data': {
|
|
'video': {
|
|
'comments': {
|
|
'edges': [
|
|
{
|
|
'node': {
|
|
'id': 'c1',
|
|
'contentOffsetSeconds': 10,
|
|
'commenter': {
|
|
'id': 'u1',
|
|
'displayName': 'User1',
|
|
'login': 'user1',
|
|
},
|
|
'message': {
|
|
'userColor': '#FF0000',
|
|
'userBadges': <Map<String, dynamic>>[],
|
|
'fragments': [
|
|
{'text': 'hello', 'emote': null},
|
|
],
|
|
},
|
|
},
|
|
'cursor': 'cursor-1',
|
|
},
|
|
],
|
|
'pageInfo': {'hasNextPage': true},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
data: {
|
|
'operationName': 'VideoCommentsByOffsetOrCursor',
|
|
'variables': {
|
|
'videoID': 'v123',
|
|
'contentOffsetSeconds': 0,
|
|
},
|
|
'extensions': {
|
|
'persistedQuery': {
|
|
'version': 1,
|
|
'sha256Hash':
|
|
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
|
|
},
|
|
},
|
|
},
|
|
headers: {'Client-ID': twitchGqlMobileClientId},
|
|
);
|
|
|
|
final response = await api.getVideoComments(
|
|
videoId: 'v123',
|
|
contentOffsetSeconds: 0,
|
|
);
|
|
|
|
expect(response.comments, hasLength(1));
|
|
expect(response.comments.first.id, 'c1');
|
|
expect(response.comments.first.commenterLogin, 'user1');
|
|
expect(response.hasNextPage, isTrue);
|
|
expect(response.cursor, 'cursor-1');
|
|
});
|
|
|
|
test('fetches comments by cursor', () async {
|
|
dioAdapter.onPost(
|
|
'https://gql.twitch.tv/gql',
|
|
(server) => server.reply(200, {
|
|
'data': {
|
|
'video': {
|
|
'comments': {
|
|
'edges': [
|
|
{
|
|
'node': {
|
|
'id': 'c2',
|
|
'contentOffsetSeconds': 20,
|
|
'commenter': {
|
|
'id': 'u2',
|
|
'displayName': 'User2',
|
|
'login': 'user2',
|
|
},
|
|
'message': {
|
|
'userColor': null,
|
|
'userBadges': <Map<String, dynamic>>[],
|
|
'fragments': [
|
|
{'text': 'world', 'emote': null},
|
|
],
|
|
},
|
|
},
|
|
'cursor': 'cursor-2',
|
|
},
|
|
],
|
|
'pageInfo': {'hasNextPage': false},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
data: {
|
|
'operationName': 'VideoCommentsByOffsetOrCursor',
|
|
'variables': {
|
|
'videoID': 'v123',
|
|
'cursor': 'cursor-1',
|
|
},
|
|
'extensions': {
|
|
'persistedQuery': {
|
|
'version': 1,
|
|
'sha256Hash':
|
|
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
|
|
},
|
|
},
|
|
},
|
|
headers: {'Client-ID': twitchGqlMobileClientId},
|
|
);
|
|
|
|
final response = await api.getVideoComments(
|
|
videoId: 'v123',
|
|
cursor: 'cursor-1',
|
|
);
|
|
|
|
expect(response.comments, hasLength(1));
|
|
expect(response.comments.first.id, 'c2');
|
|
expect(response.hasNextPage, isFalse);
|
|
});
|
|
|
|
test('handles null video (VOD not found)', () async {
|
|
dioAdapter.onPost(
|
|
'https://gql.twitch.tv/gql',
|
|
(server) => server.reply(200, {
|
|
'data': {'video': null},
|
|
}),
|
|
data: {
|
|
'operationName': 'VideoCommentsByOffsetOrCursor',
|
|
'variables': {
|
|
'videoID': 'nonexistent',
|
|
'contentOffsetSeconds': 0,
|
|
},
|
|
'extensions': {
|
|
'persistedQuery': {
|
|
'version': 1,
|
|
'sha256Hash':
|
|
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
|
|
},
|
|
},
|
|
},
|
|
headers: {'Client-ID': twitchGqlMobileClientId},
|
|
);
|
|
|
|
final response = await api.getVideoComments(
|
|
videoId: 'nonexistent',
|
|
contentOffsetSeconds: 0,
|
|
);
|
|
|
|
expect(response.comments, isEmpty);
|
|
expect(response.hasNextPage, isFalse);
|
|
});
|
|
|
|
test('throws on GQL errors', () async {
|
|
dioAdapter.onPost(
|
|
'https://gql.twitch.tv/gql',
|
|
(server) => server.reply(200, {
|
|
'errors': [
|
|
{'message': 'Something went wrong'},
|
|
],
|
|
'data': null,
|
|
}),
|
|
data: {
|
|
'operationName': 'VideoCommentsByOffsetOrCursor',
|
|
'variables': {
|
|
'videoID': 'v123',
|
|
'contentOffsetSeconds': 0,
|
|
},
|
|
'extensions': {
|
|
'persistedQuery': {
|
|
'version': 1,
|
|
'sha256Hash':
|
|
'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a',
|
|
},
|
|
},
|
|
},
|
|
headers: {'Client-ID': twitchGqlMobileClientId},
|
|
);
|
|
|
|
expect(
|
|
() => api.getVideoComments(
|
|
videoId: 'v123',
|
|
contentOffsetSeconds: 0,
|
|
),
|
|
throwsA(isA<Exception>()),
|
|
);
|
|
});
|
|
});
|
|
}
|