frosty/test/models/gql_chat_comment_test.dart

217 lines
6.4 KiB
Dart
Raw Normal View History

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:37:02 -07:00
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/gql_chat_comment.dart';
import 'package:frosty/models/irc.dart';
void main() {
group('GqlChatComment', () {
test('fromGqlNode parses a complete comment', () {
final node = {
'id': 'comment-1',
'contentOffsetSeconds': 120,
'commenter': {
'id': 'user-123',
'displayName': 'TestUser',
'login': 'testuser',
},
'message': {
'userColor': '#FF0000',
'userBadges': [
{'setID': 'subscriber', 'version': '12'},
],
'fragments': [
{'text': 'Hello world ', 'emote': null},
{
'text': 'Kappa',
'emote': {'emoteID': '25'},
},
],
},
};
final comment = GqlChatComment.fromGqlNode(node);
expect(comment.id, 'comment-1');
expect(comment.contentOffsetSeconds, 120);
expect(comment.commenterLogin, 'testuser');
expect(comment.commenterDisplayName, 'TestUser');
expect(comment.commenterId, 'user-123');
expect(comment.userColor, '#FF0000');
expect(comment.userBadges.length, 1);
expect(comment.userBadges.first.setId, 'subscriber');
expect(comment.userBadges.first.version, '12');
expect(comment.messageFragments.length, 2);
expect(comment.messageFragments[0].text, 'Hello world ');
expect(comment.messageFragments[0].emote, isNull);
expect(comment.messageFragments[1].text, 'Kappa');
expect(comment.messageFragments[1].emote!.emoteId, '25');
});
test('fromGqlNode handles deleted commenter (null)', () {
final node = {
'id': 'comment-2',
'contentOffsetSeconds': 60,
'commenter': null,
'message': {
'userColor': null,
'userBadges': <Map<String, dynamic>>[],
'fragments': [
{'text': 'deleted message', 'emote': null},
],
},
};
final comment = GqlChatComment.fromGqlNode(node);
expect(comment.commenterLogin, isNull);
expect(comment.commenterDisplayName, isNull);
expect(comment.commenterId, isNull);
expect(comment.userColor, isNull);
expect(comment.userBadges, isEmpty);
});
});
group('GqlChatResponse', () {
test('fromJson parses a valid response', () {
final json = {
'data': {
'video': {
'comments': {
'edges': [
{
'node': {
'id': 'c1',
'contentOffsetSeconds': 10,
'commenter': {
'id': 'u1',
'displayName': 'User1',
'login': 'user1',
},
'message': {
'userColor': '#00FF00',
'userBadges': <Map<String, dynamic>>[],
'fragments': [
{'text': 'hello', 'emote': null},
],
},
},
'cursor': 'cursor-abc',
},
],
'pageInfo': {'hasNextPage': true},
},
},
},
};
final response = GqlChatResponse.fromJson(json);
expect(response.comments.length, 1);
expect(response.comments.first.id, 'c1');
expect(response.hasNextPage, isTrue);
expect(response.cursor, 'cursor-abc');
});
test('fromJson throws on GQL errors', () {
final json = {
'errors': [
{'message': 'Something went wrong'},
],
'data': null,
};
expect(
() => GqlChatResponse.fromJson(json),
throwsA(isA<Exception>()),
);
});
test('fromJson handles null video (VOD not found)', () {
final json = {
'data': {'video': null},
};
final response = GqlChatResponse.fromJson(json);
expect(response.comments, isEmpty);
expect(response.hasNextPage, isFalse);
});
});
group('IRCMessage.fromGqlComment', () {
test('converts comment to IRCMessage with correct tags', () {
final comment = GqlChatComment(
id: 'msg-1',
contentOffsetSeconds: 300,
commenterLogin: 'testuser',
commenterDisplayName: 'TestUser',
commenterId: 'user-123',
userColor: '#FF0000',
userBadges: [
const GqlChatBadge(setId: 'subscriber', version: '3'),
const GqlChatBadge(setId: 'moderator', version: '1'),
],
messageFragments: [
const GqlChatFragment(text: 'Hello world'),
],
);
final irc = IRCMessage.fromGqlComment(comment);
expect(irc.command, Command.privateMessage);
expect(irc.user, 'testuser');
expect(irc.tags['display-name'], 'TestUser');
expect(irc.tags['user-id'], 'user-123');
expect(irc.tags['id'], 'msg-1');
expect(irc.tags['color'], '#FF0000');
expect(irc.tags['badges'], 'subscriber/3,moderator/1');
expect(irc.tags['tmi-sent-ts'], '300000');
expect(irc.message, 'Hello world');
expect(irc.split, ['Hello', 'world']);
expect(irc.localEmotes, isNull);
});
test('populates localEmotes for Twitch emotes', () {
final comment = GqlChatComment(
id: 'msg-2',
contentOffsetSeconds: 10,
commenterLogin: 'user',
commenterDisplayName: 'User',
commenterId: 'u1',
userBadges: [],
messageFragments: [
const GqlChatFragment(text: 'Hey '),
const GqlChatFragment(
text: 'Kappa',
emote: GqlChatEmote(emoteId: '25'),
),
const GqlChatFragment(text: ' nice'),
],
);
final irc = IRCMessage.fromGqlComment(comment);
expect(irc.message, 'Hey Kappa nice');
expect(irc.localEmotes, isNotNull);
expect(irc.localEmotes!['Kappa'], isNotNull);
expect(irc.localEmotes!['Kappa']!.name, 'Kappa');
expect(irc.localEmotes!['Kappa']!.url, contains('25'));
});
test('handles deleted commenter', () {
final comment = GqlChatComment(
id: 'msg-3',
contentOffsetSeconds: 0,
userBadges: [],
messageFragments: [
const GqlChatFragment(text: 'deleted message'),
],
);
final irc = IRCMessage.fromGqlComment(comment);
expect(irc.tags['display-name'], 'Deleted User');
expect(irc.user, isNull);
expect(irc.tags['user-id'], '');
});
});
}