Test the channel tree, the event handler and settings

- the tree's sibling chain: arrival order, broken links, removal,
  reordering and reparenting, orphans, client order, path lookup
- the event handler, fed events parsed from the server's own wording:
  joins, moves, leaves, partial and clearing updates, server groups,
  and a channel's life from creation to deletion
- settings: saving and loading back, unknown values, and the one-time
  conversion of the old dBFS voice threshold

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 11:01:50 +00:00
parent bf07f0bf49
commit 71ac2c817c
3 changed files with 369 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
package com.ts3client.config;
import com.ts3client.audio.InputLevel;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
class SettingsTest {
@TempDir
Path dir;
@BeforeEach
void freshProfile() {
AppDirs.init(dir.toFile(), dir.resolve("chats"));
}
@AfterEach
void defaultProfile() {
AppDirs.init(null, null);
}
@Test
void whatIsSavedLoadsBack() {
Settings saved = new Settings();
saved.nickname = "Žofia";
saved.inputMode = Settings.InputMode.PUSH_TO_TALK;
saved.vadMode = Settings.VadMode.AUTOMATIC;
saved.vadThresholdDb = -33.5;
saved.appearance = Settings.Appearance.DARK;
saved.whisperPolicy = Settings.WhisperPolicy.NOBODY;
saved.chatLogs = false;
saved.chatLogDir = "/somewhere/else";
saved.save();
Settings loaded = Settings.load();
assertEquals("Žofia", loaded.nickname);
assertEquals(Settings.InputMode.PUSH_TO_TALK, loaded.inputMode);
assertEquals(Settings.VadMode.AUTOMATIC, loaded.vadMode);
assertEquals(-33.5, loaded.vadThresholdDb);
assertEquals(Settings.Appearance.DARK, loaded.appearance);
assertEquals(Settings.WhisperPolicy.NOBODY, loaded.whisperPolicy);
assertFalse(loaded.chatLogs);
assertEquals("/somewhere/else", loaded.chatLogDir);
}
@Test
void unknownValuesFallBackToDefaults() throws Exception {
Files.writeString(dir.resolve("settings.properties"), "inputMode=TELEPATHY\nvadMode=\n");
Settings loaded = Settings.load();
assertEquals(new Settings().inputMode, loaded.inputMode);
assertEquals(new Settings().vadMode, loaded.vadMode);
}
@Test
void anOldDbfsThresholdIsConvertedOnce() throws Exception {
Files.writeString(dir.resolve("settings.properties"), "vadThresholdDb=-40.0\n");
Settings loaded = Settings.load();
double expected = InputLevel.powerToDb(Math.pow(10.0, -40.0 / 10.0));
assertEquals(expected, loaded.vadThresholdDb, 1e-9);
loaded.save();
String saved = Files.readString(dir.resolve("settings.properties"));
assertFalse(saved.contains("vadThresholdDb"), "the old key is dropped once converted");
assertEquals(expected, Settings.load().vadThresholdDb, 1e-9);
}
}

View File

@@ -0,0 +1,194 @@
package com.ts3client.net;
import com.github.manevolent.ts3j.command.MultiCommand;
import com.github.manevolent.ts3j.event.ChannelCreateEvent;
import com.github.manevolent.ts3j.event.ChannelDeletedEvent;
import com.github.manevolent.ts3j.event.ChannelEditedEvent;
import com.github.manevolent.ts3j.event.ChannelMovedEvent;
import com.github.manevolent.ts3j.event.ClientJoinEvent;
import com.github.manevolent.ts3j.event.ClientLeaveEvent;
import com.github.manevolent.ts3j.event.ClientMovedEvent;
import com.github.manevolent.ts3j.event.ClientUpdatedEvent;
import com.github.manevolent.ts3j.event.ServerGroupClientAddedEvent;
import com.github.manevolent.ts3j.event.ServerGroupClientDeletedEvent;
import com.github.manevolent.ts3j.protocol.ProtocolRole;
import com.ts3client.config.Settings;
import com.ts3client.contacts.ContactStore;
import com.ts3client.sound.SoundNotifier;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Feeds the handler events as the server words them and checks what the model makes of them. */
class ConnectionEventHandlerTest {
private int modelChanges;
private final Settings settings = new Settings();
private final TeamspeakConnection conn = new TeamspeakConnection(settings, null, new ConnectionListener() {
@Override
public void onStatus(String status) {
}
@Override
public void onConnected() {
}
@Override
public void onDisconnected(String reason) {
}
@Override
public void onModelChanged() {
modelChanges++;
}
@Override
public void onInfoUpdated() {
}
@Override
public void onIconsUpdated() {
}
@Override
public void onChat(ChatScope scope, int fromClientId, String fromUniqueId, String fromName, String message) {
}
@Override
public void onTalkStateChanged(int clientId, boolean talking) {
}
@Override
public void onError(String message) {
}
@Override
public void onPoke(String fromName, String message) {
}
@Override
public void onServerLog(String message) {
}
}, new SoundNotifier(settings), ContactStore.load());
private final ConnectionEventHandler events = new ConnectionEventHandler(conn);
private final ServerModel model = conn.getModel();
/** The fields of an event, parsed from the command text the server sends. */
private static Map<String, String> fields(String command) {
return MultiCommand.parse(ProtocolRole.SERVER, command).simplifyOne().toMap();
}
private void join(int clid, String nickname, int channel) {
events.onClientJoin(new ClientJoinEvent(fields("notifycliententerview cfid=0 ctid=" + channel
+ " reasonid=0 clid=" + clid + " client_unique_identifier=uid" + clid + "=="
+ " client_nickname=" + nickname + " client_input_muted=0 client_output_muted=0"
+ " client_input_hardware=1 client_output_hardware=1 client_database_id=" + (100 + clid)
+ " client_channel_group_id=8 client_servergroups=6,7 client_away=0 client_away_message"
+ " client_type=0 client_flag_avatar=abc client_talk_power=25")));
}
@Test
void aJoiningClientEntersTheModelWithWhatItReported() {
model.putChannel(1, "Lobby", 0, 0);
join(5, "alice", 1);
ClientEntry alice = model.getClient(5);
assertEquals("alice", alice.nickname);
assertEquals(1, alice.channelId);
assertEquals(105, alice.databaseId);
assertEquals(25, alice.talkPower);
assertArrayEquals(new int[]{6, 7}, alice.serverGroupIds);
assertEquals("abc", alice.avatarFlag);
assertEquals(1, modelChanges);
}
@Test
void movesAndLeaves() {
model.putChannel(1, "Lobby", 0, 0);
model.putChannel(2, "Games", 0, 1);
join(5, "alice", 1);
events.onClientMoved(new ClientMovedEvent(fields("notifyclientmoved ctid=2 reasonid=0 clid=5")));
assertEquals(2, model.getClient(5).channelId);
events.onClientLeave(new ClientLeaveEvent(fields(
"notifyclientleftview cfid=2 ctid=0 reasonid=8 reasonmsg=bye clid=5")));
assertNull(model.getClient(5));
}
@Test
void aPartialUpdateLeavesTheOtherFieldsAlone() {
model.putChannel(1, "Lobby", 0, 0);
join(5, "alice", 1);
events.onClientChanged(new ClientUpdatedEvent(fields("notifyclientupdated clid=5 client_input_muted=1")));
ClientEntry alice = model.getClient(5);
assertTrue(alice.inputMuted);
assertEquals("alice", alice.nickname);
assertEquals("abc", alice.avatarFlag);
}
@Test
void clearedFieldsAreCleared() {
model.putChannel(1, "Lobby", 0, 0);
join(5, "alice", 1);
events.onClientChanged(new ClientUpdatedEvent(fields(
"notifyclientupdated clid=5 client_away=1 client_away_message=lunch")));
assertEquals("lunch", model.getClient(5).awayMessage);
events.onClientChanged(new ClientUpdatedEvent(fields(
"notifyclientupdated clid=5 client_away_message client_flag_avatar")));
ClientEntry alice = model.getClient(5);
assertTrue(alice.away);
assertEquals("", alice.awayMessage);
assertEquals("", alice.avatarFlag);
}
@Test
void serverGroupsComeAndGo() {
model.putChannel(1, "Lobby", 0, 0);
join(5, "alice", 1);
events.onServerGroupClientAdded(new ServerGroupClientAddedEvent(fields(
"notifyservergroupclientadded name=Admin sgid=9 cldbid=105 clid=5")));
assertTrue(model.getClient(5).inServerGroup(9));
events.onServerGroupClientDeleted(new ServerGroupClientDeletedEvent(fields(
"notifyservergroupclientdeleted name=Guest sgid=6 cldbid=105 clid=5")));
assertArrayEquals(new int[]{7, 9}, model.getClient(5).serverGroupIds);
}
@Test
void channelsAreCreatedEditedMovedAndDeleted() {
model.putChannel(1, "Lobby", 0, 0);
events.onChannelCreate(new ChannelCreateEvent(fields(
"notifychannelcreated cid=2 cpid=0 channel_name=Games channel_order=1"
+ " channel_flag_password=1 channel_maxclients=5 channel_flag_maxclients_unlimited=0")));
ChannelNode games = model.getChannel(2);
assertTrue(games.hasPassword);
assertEquals(5, games.maxClients);
assertEquals(List.of("Lobby", "Games"), model.buildTree().stream().map(c -> c.name).toList());
events.onChannelEdit(new ChannelEditedEvent(fields(
"notifychanneledited cid=2 reasonid=10 invokerid=3 invokername=bob invokeruid=bobuid="
+ " channel_flag_password=0 channel_topic")));
assertFalse(games.hasPassword);
assertEquals("", games.topic);
events.onChannelMoved(new ChannelMovedEvent(fields("notifychannelmoved cid=2 cpid=1 order=0 reasonid=1")));
assertEquals(1, games.parentId);
assertEquals(List.of("Games"), model.buildTree().get(0).children.stream().map(c -> c.name).toList());
events.onChannelDeleted(new ChannelDeletedEvent(fields("notifychanneldeleted cid=2")));
assertNull(model.getChannel(2));
}
}

View File

@@ -0,0 +1,101 @@
package com.ts3client.net;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
/** The channel tree: TS3 orders siblings as a chain of "sits below" links, not by a key. */
class ServerModelTreeTest {
private final ServerModel model = new ServerModel();
private static List<String> names(List<ChannelNode> channels) {
return channels.stream().map(c -> c.name).toList();
}
@Test
void ordersSiblingsByTheirChainWhateverOrderTheyArrivedIn() {
model.putChannel(3, "C", 0, 2);
model.putChannel(1, "A", 0, 0);
model.putChannel(2, "B", 0, 1);
assertEquals(List.of("A", "B", "C"), names(model.buildTree()));
}
@Test
void keepsChannelsWhoseLinksAreBroken() {
model.putChannel(1, "A", 0, 0);
model.putChannel(2, "B", 0, 1);
model.putChannel(3, "C", 0, 1); // claims the same place as B
model.putChannel(4, "D", 0, 99); // sits below a channel that does not exist
List<String> tree = names(model.buildTree());
assertEquals(4, tree.size());
assertEquals(List.of("A", "B"), tree.subList(0, 2));
}
@Test
void nestsChannelsAndPutsOrphansAtTheTop() {
model.putChannel(1, "Lobby", 0, 0);
model.putChannel(2, "Games", 1, 0);
model.putChannel(3, "Lost", 42, 0);
List<ChannelNode> roots = model.buildTree();
assertEquals(List.of("Lobby", "Lost"), names(roots).stream().sorted().toList());
assertEquals(List.of("Games"), names(model.getChannel(1).children));
}
@Test
void aRemovedChannelsFollowerMovesUp() {
model.putChannel(1, "A", 0, 0);
model.putChannel(2, "B", 0, 1);
model.putChannel(3, "C", 0, 2);
model.removeChannel(2);
assertEquals(List.of("A", "C"), names(model.buildTree()));
}
@Test
void reorderingRepairsTheLinksTheServerDoesNotResend() {
model.putChannel(1, "A", 0, 0);
model.putChannel(2, "B", 0, 1);
model.putChannel(3, "C", 0, 2);
model.relinkChannel(3, 0, 0); // C to the top
assertEquals(List.of("C", "A", "B"), names(model.buildTree()));
model.relinkChannel(3, 0, 2); // and back below B
assertEquals(List.of("A", "B", "C"), names(model.buildTree()));
}
@Test
void movingUnderAnotherParentClosesTheGap() {
model.putChannel(1, "A", 0, 0);
model.putChannel(2, "B", 0, 1);
model.putChannel(3, "C", 0, 2);
model.relinkChannel(2, 1, 0);
assertEquals(List.of("A", "C"), names(model.buildTree()));
assertEquals(List.of("B"), names(model.getChannel(1).children));
}
@Test
void listsClientsByTalkPowerThenNameAndHidesQueryClients() {
model.putChannel(1, "Lobby", 0, 0);
model.putClient(10, "zed", 1);
model.putClient(11, "Amy", 1);
model.putClient(12, "bob", 1).talkPower = 50;
model.putClient(13, "serveradmin", 1).type = 1;
model.buildTree();
assertEquals(List.of("bob", "Amy", "zed"),
model.getChannel(1).clients.stream().map(c -> c.nickname).toList());
}
@Test
void findsChannelsByPathIdOrPlainName() {
model.putChannel(1, "Lobby", 0, 0);
ChannelNode games = model.putChannel(2, "Games", 1, 0);
assertEquals("Lobby/Games", model.channelPath(2));
assertSame(games, model.findChannelByPath("/lobby/games/"));
assertSame(games, model.findChannelByPath("/2"));
assertSame(games, model.findChannelByPath("Games"), "a moved channel is still found by name");
assertNull(model.findChannelByPath("Lobby/Music"));
}
}