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>
128 lines
3.5 KiB
Dart
128 lines
3.5 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:frosty/models/video.dart';
|
|
|
|
void main() {
|
|
group('VideoTwitch', () {
|
|
test('fromJson parses all fields correctly', () {
|
|
final json = {
|
|
'id': '12345',
|
|
'stream_id': '67890',
|
|
'user_id': '111',
|
|
'user_login': 'testuser',
|
|
'user_name': 'TestUser',
|
|
'title': 'Test Stream VOD',
|
|
'created_at': '2024-01-15T10:30:00Z',
|
|
'published_at': '2024-01-15T10:30:00Z',
|
|
'duration': '3h26m15s',
|
|
'thumbnail_url':
|
|
'https://static-cdn.jtvnw.net/cf_vods/abc/%{width}x%{height}.jpg',
|
|
'view_count': 12345,
|
|
};
|
|
|
|
final video = VideoTwitch.fromJson(json);
|
|
|
|
expect(video.id, '12345');
|
|
expect(video.streamId, '67890');
|
|
expect(video.userId, '111');
|
|
expect(video.userLogin, 'testuser');
|
|
expect(video.userName, 'TestUser');
|
|
expect(video.title, 'Test Stream VOD');
|
|
expect(video.createdAt, '2024-01-15T10:30:00Z');
|
|
expect(video.publishedAt, '2024-01-15T10:30:00Z');
|
|
expect(video.duration, '3h26m15s');
|
|
expect(video.thumbnailUrl, contains('%{width}'));
|
|
expect(video.viewCount, 12345);
|
|
});
|
|
|
|
test('fromJson handles null streamId', () {
|
|
final json = {
|
|
'id': '12345',
|
|
'stream_id': null,
|
|
'user_id': '111',
|
|
'user_login': 'testuser',
|
|
'user_name': 'TestUser',
|
|
'title': 'Test',
|
|
'created_at': '2024-01-15T10:30:00Z',
|
|
'published_at': '2024-01-15T10:30:00Z',
|
|
'duration': '1h0m0s',
|
|
'thumbnail_url': '',
|
|
'view_count': 0,
|
|
};
|
|
|
|
final video = VideoTwitch.fromJson(json);
|
|
expect(video.streamId, isNull);
|
|
});
|
|
});
|
|
|
|
group('VideosTwitch', () {
|
|
test('fromJson parses data list and pagination', () {
|
|
final json = {
|
|
'data': [
|
|
{
|
|
'id': '1',
|
|
'stream_id': null,
|
|
'user_id': '111',
|
|
'user_login': 'user',
|
|
'user_name': 'User',
|
|
'title': 'Title',
|
|
'created_at': '2024-01-01T00:00:00Z',
|
|
'published_at': '2024-01-01T00:00:00Z',
|
|
'duration': '1h0m0s',
|
|
'thumbnail_url': '',
|
|
'view_count': 100,
|
|
},
|
|
],
|
|
'pagination': {'cursor': 'abc123'},
|
|
};
|
|
|
|
final videos = VideosTwitch.fromJson(json);
|
|
expect(videos.data.length, 1);
|
|
expect(videos.data.first.id, '1');
|
|
expect(videos.pagination['cursor'], 'abc123');
|
|
});
|
|
|
|
test('fromJson handles empty data', () {
|
|
final json = {
|
|
'data': <Map<String, dynamic>>[],
|
|
'pagination': <String, String>{},
|
|
};
|
|
|
|
final videos = VideosTwitch.fromJson(json);
|
|
expect(videos.data, isEmpty);
|
|
});
|
|
});
|
|
|
|
group('parseTwitchDuration', () {
|
|
test('parses hours, minutes, and seconds', () {
|
|
expect(
|
|
parseTwitchDuration('3h26m15s'),
|
|
const Duration(hours: 3, minutes: 26, seconds: 15),
|
|
);
|
|
});
|
|
|
|
test('parses minutes and seconds only', () {
|
|
expect(
|
|
parseTwitchDuration('45m30s'),
|
|
const Duration(minutes: 45, seconds: 30),
|
|
);
|
|
});
|
|
|
|
test('parses seconds only', () {
|
|
expect(
|
|
parseTwitchDuration('30s'),
|
|
const Duration(seconds: 30),
|
|
);
|
|
});
|
|
|
|
test('parses hours and minutes only', () {
|
|
expect(
|
|
parseTwitchDuration('2h10m'),
|
|
const Duration(hours: 2, minutes: 10),
|
|
);
|
|
});
|
|
|
|
test('returns Duration.zero for empty string', () {
|
|
expect(parseTwitchDuration(''), Duration.zero);
|
|
});
|
|
});
|
|
}
|