frosty/test/models/gql_chat_comment_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

216 lines
6.4 KiB
Dart

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'], '');
});
});
}