From 71ac2c817c90d3168f79224d699d5906a32d1e25 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 25 Sep 2026 11:01:50 +0000 Subject: [PATCH] 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 --- .../com/ts3client/config/SettingsTest.java | 74 +++++++ .../net/ConnectionEventHandlerTest.java | 194 ++++++++++++++++++ .../ts3client/net/ServerModelTreeTest.java | 101 +++++++++ 3 files changed, 369 insertions(+) create mode 100644 ts3-client/core/src/test/java/com/ts3client/config/SettingsTest.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/net/ConnectionEventHandlerTest.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/net/ServerModelTreeTest.java diff --git a/ts3-client/core/src/test/java/com/ts3client/config/SettingsTest.java b/ts3-client/core/src/test/java/com/ts3client/config/SettingsTest.java new file mode 100644 index 0000000..03d38ee --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/config/SettingsTest.java @@ -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); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/net/ConnectionEventHandlerTest.java b/ts3-client/core/src/test/java/com/ts3client/net/ConnectionEventHandlerTest.java new file mode 100644 index 0000000..d0dc760 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/net/ConnectionEventHandlerTest.java @@ -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 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)); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/net/ServerModelTreeTest.java b/ts3-client/core/src/test/java/com/ts3client/net/ServerModelTreeTest.java new file mode 100644 index 0000000..bd3c5a4 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/net/ServerModelTreeTest.java @@ -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 names(List 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 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 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")); + } +}