Handle pokes outside chat tabs

This commit is contained in:
Edison Jwa
2026-05-23 05:25:36 +09:00
parent 738b274748
commit 7e28791ec2
10 changed files with 2567 additions and 1347 deletions
@@ -0,0 +1,470 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/services/ts3_server_link.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/chat_views.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
ChatEntry entry(rust.BridgeMessageTarget target) {
return ChatEntry(
senderId: BigInt.one,
senderName: 'Sender',
message: 'Message',
target: target,
);
}
rust.BridgeChannel channel(BigInt id, String name) {
return rust.BridgeChannel(
id: id,
parent: BigInt.zero,
name: name,
order: 0,
hasPassword: false,
neededTalkPower: 0,
);
}
rust.BridgeClient client({
required BigInt id,
required String name,
required BigInt channelId,
bool isServerQuery = false,
}) {
return rust.BridgeClient(
id: id,
channel: channelId,
name: name,
inputMuted: false,
outputMuted: false,
isSpeaking: false,
isServerQuery: isServerQuery,
talkPower: 0,
talkPowerGranted: false,
);
}
rust.BridgeSnapshot snapshot({
required List<rust.BridgeChannel> channels,
required List<rust.BridgeClient> clients,
}) {
return rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: channels,
clients: clients,
ownClientId: BigInt.one,
);
}
test('initial chat target opens latest private conversation first', () {
final target = resolveInitialChatTarget(
messages: [
entry(rust.BridgeMessageTarget.client(BigInt.from(1))),
entry(rust.BridgeMessageTarget.poke(BigInt.from(2))),
entry(rust.BridgeMessageTarget.client(BigInt.from(3))),
],
currentVoiceChannelId: BigInt.from(9),
);
expect(target, rust.BridgeMessageTarget.client(BigInt.from(3)));
});
test('initial chat target ignores pokes and falls back to channel', () {
final target = resolveInitialChatTarget(
messages: [
entry(const rust.BridgeMessageTarget.server()),
entry(rust.BridgeMessageTarget.poke(BigInt.from(2))),
entry(rust.BridgeMessageTarget.poke(BigInt.from(4))),
],
currentVoiceChannelId: BigInt.from(9),
);
expect(target, const rust.BridgeMessageTarget.channel());
});
test('initial chat target falls back to channel when connected to voice', () {
final target = resolveInitialChatTarget(
messages: [
entry(const rust.BridgeMessageTarget.server()),
entry(const rust.BridgeMessageTarget.channel()),
],
currentVoiceChannelId: BigInt.from(9),
);
expect(target, const rust.BridgeMessageTarget.channel());
});
test('initial chat target falls back to hub when nothing is active', () {
final target = resolveInitialChatTarget(
messages: const [],
currentVoiceChannelId: null,
);
expect(target, isNull);
});
test(
'groups chat picker clients by channel and excludes own/query clients',
() {
final groups = groupChatClientsForPicker(
snapshot(
channels: [channel(BigInt.from(10), 'Lobby')],
clients: [
client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)),
client(
id: BigInt.from(2),
name: 'Zulu',
channelId: BigInt.from(10),
),
client(
id: BigInt.from(3),
name: 'Alpha',
channelId: BigInt.from(10),
),
client(
id: BigInt.from(4),
name: 'Query',
channelId: BigInt.from(10),
isServerQuery: true,
),
client(
id: BigInt.from(5),
name: 'Orphan',
channelId: BigInt.from(99),
),
],
),
);
expect(
groups.clientsByChannel[BigInt.from(10)]?.map((client) => client.name),
['Alpha', 'Zulu'],
);
expect(groups.ungroupedClients.map((client) => client.name), ['Orphan']);
},
);
test('filters chat picker groups by client name case-insensitively', () {
final groups = filterChatClientPickerGroups(
channels: [
channel(BigInt.from(10), 'Lobby'),
channel(BigInt.from(20), 'Games'),
],
clientsByChannel: {
BigInt.from(10): [
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.from(10)),
],
BigInt.from(20): [
client(id: BigInt.from(3), name: 'Bravo', channelId: BigInt.from(20)),
],
},
ungroupedClients: [
client(id: BigInt.from(4), name: 'Alfred', channelId: BigInt.from(99)),
],
query: 'AL',
);
expect(groups.map((group) => group.channelName), ['Lobby', 'Other']);
expect(
groups.expand((group) => group.clients).map((client) => client.name),
['Alpha', 'Alfred'],
);
});
test('keeps all chat picker groups for blank queries', () {
final groups = filterChatClientPickerGroups(
channels: [channel(BigInt.from(10), 'Lobby')],
clientsByChannel: {
BigInt.from(10): [
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.from(10)),
client(id: BigInt.from(3), name: 'Bravo', channelId: BigInt.from(10)),
],
},
ungroupedClients: [
client(id: BigInt.from(4), name: 'Orphan', channelId: BigInt.from(99)),
],
query: ' ',
);
expect(groups.map((group) => group.channelName), ['Lobby', 'Other']);
expect(groups.first.clients.map((client) => client.name), [
'Alpha',
'Bravo',
]);
expect(groups.last.clients.map((client) => client.name), ['Orphan']);
});
test('omits empty chat picker groups after filtering', () {
final groups = filterChatClientPickerGroups(
channels: [
channel(BigInt.from(10), 'Lobby'),
channel(BigInt.from(20), 'Games'),
],
clientsByChannel: {
BigInt.from(10): [
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.from(10)),
],
BigInt.from(20): [
client(id: BigInt.from(3), name: 'Bravo', channelId: BigInt.from(20)),
],
},
ungroupedClients: [
client(id: BigInt.from(4), name: 'Orphan', channelId: BigInt.from(99)),
],
query: 'brav',
);
expect(groups, hasLength(1));
expect(groups.single.channelName, 'Games');
expect(groups.single.clients.single.name, 'Bravo');
});
test('formats spacer channel names for filtered chat picker groups', () {
final groups = filterChatClientPickerGroups(
channels: [channel(BigInt.from(10), '[cSpacer]Lobby')],
clientsByChannel: {
BigInt.from(10): [
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.from(10)),
],
},
ungroupedClients: const [],
query: '',
);
expect(groups.single.channelName, 'Lobby');
});
test('formats chat target titles', () {
expect(
chatTargetTitle(
const rust.BridgeMessageTarget.server(),
channelName: '',
clientName: '',
),
'Server Activity',
);
expect(
chatTargetTitle(
const rust.BridgeMessageTarget.channel(),
channelName: 'Lobby',
clientName: '',
),
'# Lobby',
);
expect(
chatTargetTitle(
rust.BridgeMessageTarget.client(BigInt.from(2)),
channelName: '',
clientName: 'Alpha',
),
'Alpha',
);
expect(
chatTargetTitle(
rust.BridgeMessageTarget.poke(BigInt.from(2)),
channelName: '',
clientName: 'Alpha',
),
'Poke: Alpha',
);
});
test('formats chat empty-state text', () {
expect(
chatEmptyTitle(const rust.BridgeMessageTarget.server()),
'No server activity yet',
);
expect(
chatEmptyBody(
const rust.BridgeMessageTarget.channel(),
channelName: 'Lobby',
),
'Start the conversation in #Lobby.',
);
expect(
chatEmptyBody(const rust.BridgeMessageTarget.channel(), channelName: ''),
'Join a channel to start chatting.',
);
expect(
chatEmptyBody(
rust.BridgeMessageTarget.client(BigInt.from(2)),
channelName: '',
),
'Select a user to start a private chat.',
);
});
test('formats chat input placeholders', () {
expect(
chatInputPlaceholder(
const rust.BridgeMessageTarget.channel(),
channelName: 'Lobby',
clientName: '',
),
'Message #Lobby...',
);
expect(
chatInputPlaceholder(
rust.BridgeMessageTarget.client(BigInt.from(2)),
channelName: '',
clientName: 'Alpha',
),
'Message Alpha...',
);
expect(
chatInputPlaceholder(
rust.BridgeMessageTarget.poke(BigInt.from(2)),
channelName: '',
clientName: 'Alpha',
),
'Poke message...',
);
});
testWidgets('chat hub does not expose pokes as a chat tab', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: ChatPage(
messages: [
entry(rust.BridgeMessageTarget.poke(BigInt.from(2))),
entry(const rust.BridgeMessageTarget.server()),
],
snapshot: snapshot(channels: const [], clients: const []),
),
),
);
expect(find.text('Direct Messages'), findsOneWidget);
expect(find.text('Server Activity'), findsOneWidget);
expect(find.text('Pokes'), findsNothing);
expect(find.text('No pokes'), findsNothing);
});
testWidgets('channel chat displays localized poke history in English', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
ChatEntry(
senderId: BigInt.from(2),
senderName: 'EdisonJwa',
message: '111',
target: rust.BridgeMessageTarget.poke(BigInt.from(2)),
timestamp: DateTime(2026, 5, 23, 5, 11, 17),
),
],
snapshot: snapshot(channels: const [], clients: const []),
initialTarget: const rust.BridgeMessageTarget.channel(),
),
),
);
expect(find.text('<05:11:17> "EdisonJwa" pokes you: 111'), findsOneWidget);
});
testWidgets('channel chat displays localized poke history in Chinese', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
ChatEntry(
senderId: BigInt.from(2),
senderName: 'EdisonJwa',
message: '',
target: rust.BridgeMessageTarget.poke(BigInt.from(2)),
timestamp: DateTime(2026, 5, 23, 5, 10, 37),
),
],
snapshot: snapshot(channels: const [], clients: const []),
initialTarget: const rust.BridgeMessageTarget.channel(),
),
),
);
expect(find.text('<05:10:37> “EdisonJwa”戳了你一下'), findsOneWidget);
});
test('blocks channel chat when no voice channel is joined', () {
expect(
canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null),
isFalse,
);
expect(
chatSendBlockedReason(const rust.BridgeMessageTarget.channel(), null),
'Join a channel to send messages.',
);
expect(
canSendToChatTarget(
const rust.BridgeMessageTarget.channel(),
BigInt.from(10),
),
isTrue,
);
expect(
chatSendBlockedReason(
const rust.BridgeMessageTarget.channel(),
BigInt.from(10),
),
isNull,
);
expect(
canSendToChatTarget(
rust.BridgeMessageTarget.client(BigInt.from(2)),
null,
),
isTrue,
);
});
testWidgets('chat message TeamSpeak links use internal handler', (
tester,
) async {
Ts3ServerLink? tapped;
await tester.pumpWidget(
MaterialApp(
home: ChatPage(
messages: [
ChatEntry(
senderId: BigInt.one,
senderName: 'Sender',
message:
'ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/',
target: rust.BridgeMessageTarget.server(),
),
],
snapshot: snapshot(channels: const [], clients: const []),
initialTarget: const rust.BridgeMessageTarget.server(),
onTs3ServerLink: (link) async => tapped = link,
),
),
);
await tester.tap(
find.text('ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/'),
);
await tester.pump();
expect(tapped, isNotNull);
expect(tapped!.host, 'teamspeak.app');
expect(tapped!.addBookmark, 'Vigorous Pro');
});
}