frosty/test/models/video_test.dart
Tommy Chow e73d9b255f add VOD browsing with synchronized chat replay
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>
2026-03-09 01:47:14 -07:00

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);
});
});
}