diff --git a/v2rayN/Directory.Packages.props b/v2rayN/Directory.Packages.props
index 453c4d40..2345daf2 100644
--- a/v2rayN/Directory.Packages.props
+++ b/v2rayN/Directory.Packages.props
@@ -9,11 +9,12 @@
+
-
+
@@ -27,8 +28,8 @@
-
+
diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/Context/CoreConfigContextBuilderTests.cs b/v2rayN/ServiceLib.Tests/CoreConfig/Context/CoreConfigContextBuilderTests.cs
new file mode 100644
index 00000000..5f185a9f
--- /dev/null
+++ b/v2rayN/ServiceLib.Tests/CoreConfig/Context/CoreConfigContextBuilderTests.cs
@@ -0,0 +1,113 @@
+using AwesomeAssertions;
+using ServiceLib.Enums;
+using ServiceLib.Handler.Builder;
+using ServiceLib.Helper;
+using ServiceLib.Models;
+using Xunit;
+
+namespace ServiceLib.Tests.CoreConfig.Context;
+
+public class CoreConfigContextBuilderTests
+{
+ [Fact]
+ public async Task ResolveNodeAsync_DirectCycleDependency_ShouldFailWithCycleError()
+ {
+ var config = CoreConfigTestFactory.CreateConfig();
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var groupAId = NewId("group-a");
+ var groupBId = NewId("group-b");
+ var groupA = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupAId, "group-a", [groupBId]);
+ var groupB = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupBId, "group-b", [groupAId]);
+
+ await UpsertProfilesAsync(groupA, groupB);
+
+ var context = CoreConfigTestFactory.CreateContext(config, groupA, ECoreType.Xray);
+ context.AllProxiesMap.Clear();
+
+ var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
+
+ validatorResult.Success.Should().BeFalse();
+ validatorResult.Errors.Should().Contain(msg => ContainsCycleDependencyMessage(msg));
+ context.AllProxiesMap.Should().NotContainKey(groupA.IndexId);
+ context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
+ }
+
+ [Fact]
+ public async Task ResolveNodeAsync_IndirectCycleDependency_ShouldFailWithCycleError()
+ {
+ var config = CoreConfigTestFactory.CreateConfig();
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var groupAId = NewId("group-a");
+ var groupBId = NewId("group-b");
+ var groupCId = NewId("group-c");
+ var groupA = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupAId, "group-a", [groupBId]);
+ var groupB = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupBId, "group-b", [groupCId]);
+ var groupC = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupCId, "group-c", [groupAId]);
+
+ await UpsertProfilesAsync(groupA, groupB, groupC);
+
+ var context = CoreConfigTestFactory.CreateContext(config, groupA, ECoreType.Xray);
+ context.AllProxiesMap.Clear();
+
+ var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
+
+ validatorResult.Success.Should().BeFalse();
+ validatorResult.Errors.Should().Contain(msg => ContainsCycleDependencyMessage(msg));
+ context.AllProxiesMap.Should().NotContainKey(groupA.IndexId);
+ context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
+ context.AllProxiesMap.Should().NotContainKey(groupC.IndexId);
+ }
+
+ [Fact]
+ public async Task ResolveNodeAsync_CycleWithValidBranch_ShouldSkipCycleAndKeepValidChild()
+ {
+ var config = CoreConfigTestFactory.CreateConfig();
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var groupAId = NewId("group-a");
+ var groupBId = NewId("group-b");
+ var leafId = NewId("leaf");
+ var groupA = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupAId, "group-a", [groupBId, leafId]);
+ var groupB = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupBId, "group-b", [groupAId]);
+ var leaf = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, leafId, "leaf");
+
+ await UpsertProfilesAsync(groupA, groupB, leaf);
+
+ var context = CoreConfigTestFactory.CreateContext(config, groupA, ECoreType.Xray);
+ context.AllProxiesMap.Clear();
+
+ var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
+
+ validatorResult.Success.Should().BeTrue();
+ validatorResult.Errors.Should().BeEmpty();
+ validatorResult.Warnings.Should().Contain(msg => ContainsCycleDependencyMessage(msg));
+
+ context.AllProxiesMap.Should().ContainKey(leaf.IndexId);
+ context.AllProxiesMap.Should().ContainKey(groupA.IndexId);
+ context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
+ groupA.GetProtocolExtra().ChildItems.Should().Be(leaf.IndexId);
+ }
+
+ private static string NewId(string prefix)
+ {
+ return $"{prefix}-{Guid.NewGuid():N}";
+ }
+
+ private static bool ContainsCycleDependencyMessage(string message)
+ {
+ return message.Contains("cycle dependency", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("循环依赖", StringComparison.Ordinal)
+ || message.Contains("循環依賴", StringComparison.Ordinal);
+ }
+
+ private static async Task UpsertProfilesAsync(params ProfileItem[] profiles)
+ {
+ SQLiteHelper.Instance.CreateTable();
+ foreach (var profile in profiles)
+ {
+ await SQLiteHelper.Instance.ReplaceAsync(profile);
+ }
+ }
+}
diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs b/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs
new file mode 100644
index 00000000..c5acd854
--- /dev/null
+++ b/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs
@@ -0,0 +1,197 @@
+using ServiceLib.Enums;
+using ServiceLib.Manager;
+using ServiceLib.Models;
+using System.Reflection;
+
+namespace ServiceLib.Tests.CoreConfig;
+
+internal static class CoreConfigTestFactory
+{
+ public static void BindAppManagerConfig(Config config)
+ {
+ var field = typeof(AppManager).GetField("_config", BindingFlags.Instance | BindingFlags.NonPublic);
+ field?.SetValue(AppManager.Instance, config);
+ }
+
+ public static Config CreateConfig(ECoreType vmessCoreType = ECoreType.Xray)
+ {
+ return new Config
+ {
+ CoreBasicItem = new CoreBasicItem { Loglevel = "warning", MuxEnabled = false },
+ TunModeItem = new TunModeItem { EnableTun = false, IcmpRouting = "default" },
+ KcpItem = new KcpItem(),
+ GrpcItem = new GrpcItem(),
+ RoutingBasicItem =
+ new RoutingBasicItem
+ {
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ RoutingIndexId = string.Empty,
+ },
+ GuiItem = new GUIItem { EnableStatistics = false, DisplayRealTimeSpeed = false, EnableLog = false },
+ MsgUIItem = new MsgUIItem(),
+ UiItem =
+ new UIItem
+ {
+ CurrentLanguage = "en", CurrentFontFamily = "sans", MainColumnItem = [], WindowSizeItem = []
+ },
+ ConstItem = new ConstItem(),
+ SpeedTestItem = new SpeedTestItem
+ {
+ SpeedPingTestUrl = Global.SpeedPingTestUrls.First(),
+ SpeedTestUrl = Global.SpeedTestUrls.First(),
+ SpeedTestTimeout = 10,
+ MixedConcurrencyCount = 1,
+ IPAPIUrl = string.Empty,
+ },
+ Mux4RayItem = new Mux4RayItem { Concurrency = 8, XudpConcurrency = 16, XudpProxyUDP443 = "reject" },
+ Mux4SboxItem = new Mux4SboxItem { Protocol = Global.SingboxMuxs.First(), MaxConnections = 8 },
+ HysteriaItem = new HysteriaItem { UpMbps = 100, DownMbps = 100 },
+ ClashUIItem = new ClashUIItem { ConnectionsColumnItem = [] },
+ SystemProxyItem =
+ new SystemProxyItem
+ {
+ SystemProxyExceptions = string.Empty, SystemProxyAdvancedProtocol = string.Empty
+ },
+ WebDavItem = new WebDavItem(),
+ CheckUpdateItem = new CheckUpdateItem(),
+ Fragment4RayItem = new Fragment4RayItem { Packets = "tlshello", Length = "100-200", Interval = "10-20" },
+ Inbound =
+ [
+ new InItem
+ {
+ Protocol = nameof(EInboundProtocol.socks),
+ LocalPort = 10808,
+ UdpEnabled = true,
+ SniffingEnabled = true,
+ RouteOnly = false,
+ DestOverride = ["http", "tls"],
+ }
+ ],
+ GlobalHotkeys = [],
+ CoreTypeItem =
+ [
+ new CoreTypeItem { ConfigType = EConfigType.VMess, CoreType = vmessCoreType }
+ ],
+ SimpleDNSItem = new SimpleDNSItem
+ {
+ BootstrapDNS = Global.DomainPureIPDNSAddress.FirstOrDefault(),
+ ServeStale = false,
+ ParallelQuery = false,
+ Strategy4Freedom = Global.AsIs,
+ Strategy4Proxy = Global.AsIs,
+ },
+ IndexId = string.Empty,
+ SubIndexId = string.Empty,
+ };
+ }
+
+ public static ProfileItem CreateVmessNode(ECoreType coreType, string indexId = "node-1", string remarks = "demo")
+ {
+ var node = new ProfileItem
+ {
+ IndexId = indexId,
+ ConfigType = EConfigType.VMess,
+ CoreType = coreType,
+ Remarks = remarks,
+ Address = "example.com",
+ Port = 443,
+ Password = Guid.NewGuid().ToString(),
+ Network = nameof(ETransport.raw),
+ StreamSecurity = string.Empty,
+ Subid = string.Empty,
+ };
+
+ node.SetProtocolExtra(node.GetProtocolExtra() with { AlterId = "0", VmessSecurity = Global.DefaultSecurity, });
+
+ return node;
+ }
+
+ public static ProfileItem CreateSocksNode(ECoreType coreType, string indexId = "node-socks-1",
+ string remarks = "demo-socks")
+ {
+ return new ProfileItem
+ {
+ IndexId = indexId,
+ ConfigType = EConfigType.SOCKS,
+ CoreType = coreType,
+ Remarks = remarks,
+ Address = "127.0.0.1",
+ Port = 1080,
+ Password = "pass",
+ Username = "user",
+ Network = nameof(ETransport.raw),
+ StreamSecurity = string.Empty,
+ Subid = string.Empty,
+ };
+ }
+
+ public static ProfileItem CreatePolicyGroupNode(ECoreType coreType, string indexId, string remarks,
+ IEnumerable childIndexIds)
+ {
+ var node = new ProfileItem
+ {
+ IndexId = indexId, ConfigType = EConfigType.PolicyGroup, CoreType = coreType, Remarks = remarks,
+ };
+ node.SetProtocolExtra(node.GetProtocolExtra() with
+ {
+ GroupType = nameof(EConfigType.PolicyGroup), ChildItems = string.Join(",", childIndexIds),
+ });
+
+ return node;
+ }
+
+ public static ProfileItem CreateProxyChainNode(ECoreType coreType, string indexId, string remarks,
+ IEnumerable childIndexIds)
+ {
+ var node = new ProfileItem
+ {
+ IndexId = indexId, ConfigType = EConfigType.ProxyChain, CoreType = coreType, Remarks = remarks,
+ };
+ node.SetProtocolExtra(node.GetProtocolExtra() with
+ {
+ GroupType = nameof(EConfigType.ProxyChain), ChildItems = string.Join(",", childIndexIds),
+ });
+
+ return node;
+ }
+
+ public static CoreConfigContext CreateContext(Config config, ProfileItem node, ECoreType runCoreType)
+ {
+ return new CoreConfigContext
+ {
+ Node = node,
+ RunCoreType = runCoreType,
+ AppConfig = config,
+ RoutingItem = new RoutingItem
+ {
+ Id = "r1",
+ Remarks = "default",
+ RuleSet = "[]",
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ },
+ RawDnsItem = null,
+ SimpleDnsItem = config.SimpleDNSItem,
+ AllProxiesMap = new Dictionary { [node.IndexId] = node },
+ FullConfigTemplate = null,
+ IsTunEnabled = false,
+ ProtectDomainList = [],
+ };
+ }
+
+ public static Config CreateConfigWithDirectExpectedIPs(ECoreType coreType,
+ string directExpectedIPs = "192.168.0.0/16,geoip:cn")
+ {
+ var config = CreateConfig(coreType);
+ config.SimpleDNSItem.DirectExpectedIPs = directExpectedIPs;
+ return config;
+ }
+
+ public static Config CreateConfigWithBootstrapDNS(ECoreType coreType, string bootstrapDns = "8.8.8.8")
+ {
+ var config = CreateConfig(coreType);
+ config.SimpleDNSItem.BootstrapDNS = bootstrapDns;
+ return config;
+ }
+}
diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs b/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs
new file mode 100644
index 00000000..02aaac6f
--- /dev/null
+++ b/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs
@@ -0,0 +1,511 @@
+using AwesomeAssertions;
+using ServiceLib.Common;
+using ServiceLib.Enums;
+using ServiceLib.Models;
+using ServiceLib.Services.CoreConfig;
+using Xunit;
+
+namespace ServiceLib.Tests.CoreConfig.Singbox;
+
+public class CoreConfigSingboxServiceTests
+{
+ [Fact]
+ public void GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+ var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box);
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ result.Data.Should().NotBeNull();
+
+ var singboxConfig = JsonUtils.Deserialize(result.Data!.ToString());
+ singboxConfig.Should().NotBeNull();
+ singboxConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks");
+ singboxConfig.inbounds.Should().Contain(i => i.type == nameof(EInboundProtocol.mixed));
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildSelector()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n1", "node-1");
+ var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n2", "node-2");
+ var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.sing_box, "g1", "group",
+ [n1.IndexId, n2.IndexId]);
+
+ var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.sing_box);
+ context.AllProxiesMap[n1.IndexId] = n1;
+ context.AllProxiesMap[n2.IndexId] = n2;
+ context.AllProxiesMap[group.IndexId] = group;
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
+ cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_ProxyChain_ShouldBuildDetourChain()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n1", "node-1");
+ var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n2", "node-2");
+ var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.sing_box, "c1", "chain",
+ [n1.IndexId, n2.IndexId]);
+
+ var context = CoreConfigTestFactory.CreateContext(config, chain, ECoreType.sing_box);
+ context.AllProxiesMap[n1.IndexId] = n1;
+ context.AllProxiesMap[n2.IndexId] = n2;
+ context.AllProxiesMap[chain.IndexId] = chain;
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks");
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
+ cfg.outbounds.Should().Contain(o =>
+ o.tag == Global.ProxyTag &&
+ (o.detour ?? string.Empty).StartsWith("chain-proxy-1-", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n1", "node-1");
+ var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n2", "node-2");
+ var n3 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n3", "node-3");
+ var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.sing_box, "c1", "chain",
+ [n1.IndexId, n2.IndexId]);
+ var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.sing_box, "g1", "group",
+ [chain.IndexId, n3.IndexId]);
+
+ var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.sing_box);
+ context.AllProxiesMap[n1.IndexId] = n1;
+ context.AllProxiesMap[n2.IndexId] = n2;
+ context.AllProxiesMap[n3.IndexId] = n3;
+ context.AllProxiesMap[chain.IndexId] = chain;
+ context.AllProxiesMap[group.IndexId] = group;
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
+ cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n1", "node-1");
+ var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n2", "node-2");
+ var n3 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n3", "node-3");
+ var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.sing_box, "g1", "group",
+ [n1.IndexId, n2.IndexId]);
+ var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.sing_box, "c1", "chain",
+ [group.IndexId, n3.IndexId]);
+
+ var context = CoreConfigTestFactory.CreateContext(config, chain, ECoreType.sing_box);
+ context.AllProxiesMap[n1.IndexId] = n1;
+ context.AllProxiesMap[n2.IndexId] = n2;
+ context.AllProxiesMap[n3.IndexId] = n3;
+ context.AllProxiesMap[group.IndexId] = group;
+ context.AllProxiesMap[chain.IndexId] = chain;
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
+ cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal));
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal));
+
+ var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal));
+ proxyCloneCount.Should().Be(2);
+
+ var allCloneDetoursPointToGroupBranches = cfg.outbounds
+ .Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal))
+ .All(o => (o.detour ?? string.Empty).StartsWith("chain-proxy-1-group-", StringComparison.Ordinal));
+ allCloneDetoursPointToGroupBranches.Should().BeTrue();
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
+ {
+ RoutingItem = new RoutingItem
+ {
+ Id = "r-split-1",
+ Remarks = "split-direct-block",
+ RuleSet = JsonUtils.Serialize(new List
+ {
+ new()
+ {
+ Enabled = true,
+ RuleType = ERuleType.Routing,
+ OutboundTag = Global.DirectTag,
+ Domain = ["full:direct.example.com"],
+ },
+ new()
+ {
+ Enabled = true,
+ RuleType = ERuleType.Routing,
+ OutboundTag = Global.BlockTag,
+ Domain = ["full:block.example.com"],
+ }
+ }),
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ }
+ };
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ var hasDirectRule = cfg.route.rules.Any(r =>
+ r.domain != null
+ && r.domain.Contains("direct.example.com")
+ && r.outbound == Global.DirectTag);
+ hasDirectRule.Should().BeTrue();
+
+ var hasBlockRule = cfg.route.rules.Any(r =>
+ r.domain != null
+ && r.domain.Contains("block.example.com")
+ && r.action == "reject");
+ hasBlockRule.Should().BeTrue();
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
+ var routeNode = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-route", "route-node");
+
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
+ {
+ RoutingItem = new RoutingItem
+ {
+ Id = "r-split-2",
+ Remarks = "split-remark",
+ RuleSet = JsonUtils.Serialize(new List
+ {
+ new()
+ {
+ Enabled = true,
+ RuleType = ERuleType.Routing,
+ OutboundTag = routeNode.Remarks,
+ Domain = ["full:route.example.com"],
+ }
+ }),
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ }
+ };
+ context.AllProxiesMap[$"remark:{routeNode.Remarks}"] = routeNode;
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+ var expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}";
+
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal));
+
+ var hasRouteRule = cfg.route.rules.Any(r =>
+ r.domain != null
+ && r.domain.Contains("route.example.com")
+ && (r.outbound ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal));
+ hasRouteRule.Should().BeTrue();
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_DirectExpectedIPs_ShouldApplyGeoipAndCidrToDirectDnsRule()
+ {
+ var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(
+ ECoreType.sing_box,
+ "192.168.0.0/16,geoip:cn");
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
+ {
+ RoutingItem = new RoutingItem
+ {
+ Id = "r-dns-direct-expected",
+ Remarks = "dns-direct-expected",
+ RuleSet = JsonUtils.Serialize(new List
+ {
+ new()
+ {
+ Enabled = true,
+ RuleType = ERuleType.DNS,
+ OutboundTag = Global.DirectTag,
+ Domain = ["geosite:cn"],
+ }
+ }),
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ }
+ };
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ var hasExpectedRule = cfg.dns.rules?.Any(r =>
+ r.server == Global.SingboxDirectDNSTag
+ && r.ip_cidr?.Contains("192.168.0.0/16") == true
+ && r.rule_set?.Contains("geosite-cn") == true
+ && r.rule_set?.Contains("geoip-cn") == true) ?? false;
+
+ hasExpectedRule.Should().BeTrue();
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_BootstrapDNS_ShouldConfigurePureIPResolver()
+ {
+ var bootstrapDns = "8.8.8.8";
+ var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.sing_box, bootstrapDns);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ config.SimpleDNSItem.BootstrapDNS.Should().Be(bootstrapDns);
+
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+ var bootstrapServer = cfg.dns.servers?.FirstOrDefault(s => s.tag == Global.SingboxLocalDNSTag);
+ bootstrapServer.Should().NotBeNull();
+ (bootstrapServer?.server ?? string.Empty).Should().Contain(bootstrapDns);
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectFinalDns()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
+ config.SimpleDNSItem.DirectDNS = "1.1.1.1";
+ config.SimpleDNSItem.RemoteDNS = "9.9.9.9";
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
+ {
+ RoutingItem = new RoutingItem
+ {
+ Id = "r-direct-final",
+ Remarks = "direct-final",
+ RuleSet = JsonUtils.Serialize(new List
+ {
+ new()
+ {
+ Enabled = true,
+ RuleType = ERuleType.Routing,
+ OutboundTag = Global.DirectTag,
+ Ip = ["0.0.0.0/0"],
+ Port = "0-65535",
+ Network = "tcp,udp",
+ }
+ }),
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ }
+ };
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ cfg.dns.final.Should().Be(Global.SingboxDirectDNSTag);
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedRule()
+ {
+ var config =
+ CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.sing_box, "192.168.0.0/16,geoip:cn");
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
+ {
+ RoutingItem = new RoutingItem
+ {
+ Id = "r-dns-direct-unmatched",
+ Remarks = "dns-direct-unmatched",
+ RuleSet = JsonUtils.Serialize(new List
+ {
+ new()
+ {
+ Enabled = true,
+ RuleType = ERuleType.DNS,
+ OutboundTag = Global.DirectTag,
+ Domain = ["geosite:us"],
+ }
+ }),
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ }
+ };
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ var hasExpectedRule = cfg.dns.rules?.Any(r =>
+ r.server == Global.SingboxDirectDNSTag
+ && r.ip_cidr?.Contains("192.168.0.0/16") == true
+ && r.rule_set?.Contains("geoip-cn") == true) ?? false;
+ hasExpectedRule.Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData("geosite:cn", "geosite-cn")]
+ [InlineData("geosite:geolocation-cn", "geosite-geolocation-cn")]
+ [InlineData("geosite:tld-cn", "geosite-tld-cn")]
+ public void GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedRule(string domainTag,
+ string expectedRuleSetTag)
+ {
+ var config =
+ CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.sing_box, "192.168.0.0/16,geoip:cn");
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
+ {
+ RoutingItem = new RoutingItem
+ {
+ Id = "r-dns-direct-variant",
+ Remarks = "dns-direct-variant",
+ RuleSet = JsonUtils.Serialize(new List
+ {
+ new()
+ {
+ Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.DirectTag, Domain = [domainTag],
+ }
+ }),
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ }
+ };
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ var hasExpectedRule = cfg.dns.rules?.Any(r =>
+ r.server == Global.SingboxDirectDNSTag
+ && r.ip_cidr?.Contains("192.168.0.0/16") == true
+ && r.rule_set?.Contains(expectedRuleSetTag) == true
+ && r.rule_set?.Contains("geoip-cn") == true) ?? false;
+ hasExpectedRule.Should().BeTrue();
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_Hosts_ShouldPopulateHostsServerAndDomainResolver()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
+ config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1";
+ config.SimpleDNSItem.DirectDNS = "https://resolver.example/dns-query";
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ var hostsServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxHostsDNSTag);
+ hostsServer.Should().NotBeNull();
+ hostsServer!.predefined.Should().ContainKey("resolver.example");
+ hostsServer.predefined!["resolver.example"].Should().Contain("1.1.1.1");
+
+ var directServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxDirectDNSTag);
+ directServer.Should().NotBeNull();
+ directServer!.domain_resolver.Should().Be(Global.SingboxHostsDNSTag);
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsAndInjectLocalResolver()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
+ var rawDns = new Dns4Sbox
+ {
+ servers =
+ [
+ new Server4Sbox { tag = "remote", address = "8.8.8.8", detour = Global.ProxyTag, }
+ ],
+ rules = [],
+ };
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
+ {
+ RawDnsItem = new DNSItem
+ {
+ Id = "dns-raw-1",
+ Remarks = "raw",
+ Enabled = true,
+ CoreType = ECoreType.sing_box,
+ NormalDNS = JsonUtils.Serialize(rawDns),
+ DomainDNSAddress = "1.1.1.1",
+ }
+ };
+
+ var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue($"ret msg: {result.Msg}");
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ cfg.dns.servers.Should().Contain(s => s.tag == "remote" && s.address == "8.8.8.8");
+ cfg.dns.servers.Should().Contain(s => s.tag == Global.SingboxLocalDNSTag);
+ cfg.dns.rules.Should().Contain(r => r.clash_mode == ERuleMode.Global.ToString());
+ cfg.dns.rules.Should().Contain(r => r.clash_mode == ERuleMode.Direct.ToString());
+ }
+}
diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs b/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs
new file mode 100644
index 00000000..29b26648
--- /dev/null
+++ b/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs
@@ -0,0 +1,539 @@
+using AwesomeAssertions;
+using ServiceLib.Common;
+using ServiceLib.Enums;
+using ServiceLib.Models;
+using ServiceLib.Services.CoreConfig;
+using Xunit;
+
+namespace ServiceLib.Tests.CoreConfig.V2ray;
+
+public class CoreConfigV2rayServiceTests
+{
+ [Fact]
+ public void GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+ var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray);
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ result.Data.Should().NotBeNull();
+
+ var v2rayConfig = JsonUtils.Deserialize(result.Data!.ToString());
+ v2rayConfig.Should().NotBeNull();
+ v2rayConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.protocol == "vmess");
+ v2rayConfig.inbounds.Should().Contain(i => i.protocol == nameof(EInboundProtocol.mixed));
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildBalancer()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n1", "node-1");
+ var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n2", "node-2");
+ var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "g1", "group",
+ [n1.IndexId, n2.IndexId]);
+
+ var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.Xray);
+ context.AllProxiesMap[n1.IndexId] = n1;
+ context.AllProxiesMap[n2.IndexId] = n2;
+ context.AllProxiesMap[group.IndexId] = group;
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
+ cfg.routing.balancers.Should().NotBeNull();
+ cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_ProxyChain_ShouldBuildDialerProxyChain()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n1", "node-1");
+ var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n2", "node-2");
+ var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.Xray, "c1", "chain", [n1.IndexId, n2.IndexId]);
+
+ var context = CoreConfigTestFactory.CreateContext(config, chain, ECoreType.Xray);
+ context.AllProxiesMap[n1.IndexId] = n1;
+ context.AllProxiesMap[n2.IndexId] = n2;
+ context.AllProxiesMap[chain.IndexId] = chain;
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
+ var hasDialerChain = cfg.outbounds.Any(o =>
+ o.tag == Global.ProxyTag
+ && o.streamSettings is not null
+ && o.streamSettings.sockopt is not null
+ && (o.streamSettings.sockopt.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-",
+ StringComparison.Ordinal));
+ hasDialerChain.Should().BeTrue();
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n1", "node-1");
+ var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n2", "node-2");
+ var n3 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n3", "node-3");
+ var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.Xray, "c1", "chain", [n1.IndexId, n2.IndexId]);
+ var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "g1", "group",
+ [chain.IndexId, n3.IndexId]);
+
+ var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.Xray);
+ context.AllProxiesMap[n1.IndexId] = n1;
+ context.AllProxiesMap[n2.IndexId] = n2;
+ context.AllProxiesMap[n3.IndexId] = n3;
+ context.AllProxiesMap[chain.IndexId] = chain;
+ context.AllProxiesMap[group.IndexId] = group;
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
+ cfg.routing.balancers.Should().NotBeNull();
+ cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n1", "node-1");
+ var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n2", "node-2");
+ var n3 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n3", "node-3");
+ var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "g1", "group",
+ [n1.IndexId, n2.IndexId]);
+ var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.Xray, "c1", "chain",
+ [group.IndexId, n3.IndexId]);
+
+ var context = CoreConfigTestFactory.CreateContext(config, chain, ECoreType.Xray);
+ context.AllProxiesMap[n1.IndexId] = n1;
+ context.AllProxiesMap[n2.IndexId] = n2;
+ context.AllProxiesMap[n3.IndexId] = n3;
+ context.AllProxiesMap[group.IndexId] = group;
+ context.AllProxiesMap[chain.IndexId] = chain;
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal));
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal));
+
+ var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal));
+ proxyCloneCount.Should().Be(2);
+
+ var allCloneDialersPointToGroupBranches = cfg.outbounds
+ .Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal))
+ .All(o => (o.streamSettings?.sockopt?.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-group-",
+ StringComparison.Ordinal));
+ allCloneDialersPointToGroupBranches.Should().BeTrue();
+
+ cfg.routing.balancers.Should().NotBeNull();
+ cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
+ {
+ RoutingItem = new RoutingItem
+ {
+ Id = "r-split-1",
+ Remarks = "split-direct-block",
+ RuleSet = JsonUtils.Serialize(new List
+ {
+ new()
+ {
+ Enabled = true,
+ RuleType = ERuleType.Routing,
+ OutboundTag = Global.DirectTag,
+ Domain = ["full:direct.example.com"],
+ },
+ new()
+ {
+ Enabled = true,
+ RuleType = ERuleType.Routing,
+ OutboundTag = Global.BlockTag,
+ Domain = ["full:block.example.com"],
+ }
+ }),
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ }
+ };
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+
+ var hasDirectRule = cfg.routing.rules.Any(r =>
+ r.domain != null
+ && r.domain.Contains("full:direct.example.com")
+ && r.outboundTag == Global.DirectTag);
+ hasDirectRule.Should().BeTrue();
+
+ var hasBlockRule = cfg.routing.rules.Any(r =>
+ r.domain != null
+ && r.domain.Contains("full:block.example.com")
+ && r.outboundTag == Global.BlockTag);
+ hasBlockRule.Should().BeTrue();
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
+ var routeNode = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n-route", "route-node");
+
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
+ {
+ RoutingItem = new RoutingItem
+ {
+ Id = "r-split-2",
+ Remarks = "split-remark",
+ RuleSet = JsonUtils.Serialize(new List
+ {
+ new()
+ {
+ Enabled = true,
+ RuleType = ERuleType.Routing,
+ OutboundTag = routeNode.Remarks,
+ Domain = ["full:route.example.com"],
+ }
+ }),
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ }
+ };
+ context.AllProxiesMap[$"remark:{routeNode.Remarks}"] = routeNode;
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+ var expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}";
+
+ cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal));
+ var hasRouteRule = cfg.routing.rules.Any(r =>
+ r.domain != null
+ && r.domain.Contains("full:route.example.com")
+ && (r.outboundTag ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal));
+ hasRouteRule.Should().BeTrue();
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_DirectExpectedIPs_ShouldApplyExpectedIPsToDirectDnsServer()
+ {
+ var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
+ {
+ RoutingItem = new RoutingItem
+ {
+ Id = "r-dns-direct-expected",
+ Remarks = "dns-direct-expected",
+ RuleSet = JsonUtils.Serialize(new List
+ {
+ new()
+ {
+ Enabled = true,
+ RuleType = ERuleType.DNS,
+ OutboundTag = Global.DirectTag,
+ Domain = ["geosite:cn"],
+ }
+ }),
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ }
+ };
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+ var dns = JsonUtils.Deserialize(JsonUtils.Serialize(cfg.dns))!;
+
+ var dnsServers = dns.servers
+ .Select(s => JsonUtils.Deserialize(JsonUtils.Serialize(s)))
+ .Where(s => s is not null)
+ .Cast()
+ .ToList();
+
+ var hasExpectedServer = dnsServers.Any(s =>
+ (s.tag ?? string.Empty).StartsWith(Global.DirectDnsTag, StringComparison.Ordinal)
+ && s.domains?.Contains("geosite:cn") == true
+ && s.expectedIPs?.Contains("192.168.0.0/16") == true
+ && s.expectedIPs?.Contains("geoip:cn") == true);
+ hasExpectedServer.Should().BeTrue();
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_BootstrapDNS_ShouldApplyToDnsServerDomains()
+ {
+ var bootstrapDns = "8.8.8.8";
+ var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.Xray, bootstrapDns);
+ config.SimpleDNSItem.DirectDNS = "https://dns-direct.example/dns-query";
+ config.SimpleDNSItem.RemoteDNS = "https://dns-remote.example/dns-query";
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+ var dns = JsonUtils.Deserialize(JsonUtils.Serialize(cfg.dns))!;
+
+ var dnsServers = dns.servers
+ .Select(s => JsonUtils.Deserialize(JsonUtils.Serialize(s)))
+ .Where(s => s is not null)
+ .Cast()
+ .ToList();
+
+ var hasBootstrapServer = dnsServers.Any(s =>
+ s.address == bootstrapDns
+ && s.domains?.Contains("full:dns-direct.example") == true
+ && s.domains?.Contains("full:dns-remote.example") == true);
+ hasBootstrapServer.Should().BeTrue();
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectDnsServers()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
+ config.SimpleDNSItem.DirectDNS = "1.1.1.1";
+ config.SimpleDNSItem.RemoteDNS = "9.9.9.9";
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
+ {
+ RoutingItem = new RoutingItem
+ {
+ Id = "r-direct-final",
+ Remarks = "direct-final",
+ RuleSet = JsonUtils.Serialize(new List
+ {
+ new()
+ {
+ Enabled = true,
+ RuleType = ERuleType.Routing,
+ OutboundTag = Global.DirectTag,
+ Ip = ["0.0.0.0/0"],
+ Port = "0-65535",
+ Network = "tcp,udp",
+ }
+ }),
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ }
+ };
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+ var dns = JsonUtils.Deserialize(JsonUtils.Serialize(cfg.dns))!;
+ var dnsServers = dns.servers
+ .Select(s => JsonUtils.Deserialize(JsonUtils.Serialize(s)))
+ .Where(s => s is not null)
+ .Cast()
+ .ToList();
+
+ var hasDirectFallback = dnsServers.Any(s =>
+ (s.tag ?? string.Empty).StartsWith(Global.DirectDnsTag, StringComparison.Ordinal)
+ && s.address == "1.1.1.1");
+ hasDirectFallback.Should().BeTrue();
+
+ var hasRemoteFallback = dnsServers.Any(s => s.address == "9.9.9.9");
+ hasRemoteFallback.Should().BeFalse();
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedIPs()
+ {
+ var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
+ {
+ RoutingItem = new RoutingItem
+ {
+ Id = "r-dns-direct-unmatched",
+ Remarks = "dns-direct-unmatched",
+ RuleSet = JsonUtils.Serialize(new List
+ {
+ new()
+ {
+ Enabled = true,
+ RuleType = ERuleType.DNS,
+ OutboundTag = Global.DirectTag,
+ Domain = ["geosite:us"],
+ }
+ }),
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ }
+ };
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+ var dns = JsonUtils.Deserialize(JsonUtils.Serialize(cfg.dns))!;
+ var dnsServers = dns.servers
+ .Select(s => JsonUtils.Deserialize(JsonUtils.Serialize(s)))
+ .Where(s => s is not null)
+ .Cast()
+ .ToList();
+
+ var hasExpectedIPs = dnsServers.Any(s =>
+ s.expectedIPs?.Contains("192.168.0.0/16") == true
+ || s.expectedIPs?.Contains("geoip:cn") == true);
+ hasExpectedIPs.Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData("geosite:cn")]
+ [InlineData("geosite:geolocation-cn")]
+ [InlineData("geosite:tld-cn")]
+ public void GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedIPs(string domainTag)
+ {
+ var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
+ {
+ RoutingItem = new RoutingItem
+ {
+ Id = "r-dns-direct-variant",
+ Remarks = "dns-direct-variant",
+ RuleSet = JsonUtils.Serialize(new List
+ {
+ new()
+ {
+ Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.DirectTag, Domain = [domainTag],
+ }
+ }),
+ DomainStrategy = Global.AsIs,
+ DomainStrategy4Singbox = string.Empty,
+ }
+ };
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+ var dns = JsonUtils.Deserialize(JsonUtils.Serialize(cfg.dns))!;
+ var dnsServers = dns.servers
+ .Select(s => JsonUtils.Deserialize(JsonUtils.Serialize(s)))
+ .Where(s => s is not null)
+ .Cast()
+ .ToList();
+
+ var hasExpectedServer = dnsServers.Any(s =>
+ (s.tag ?? string.Empty).StartsWith(Global.DirectDnsTag, StringComparison.Ordinal)
+ && s.domains?.Contains(domainTag) == true
+ && s.expectedIPs?.Contains("192.168.0.0/16") == true
+ && s.expectedIPs?.Contains("geoip:cn") == true);
+ hasExpectedServer.Should().BeTrue();
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_Hosts_ShouldPopulateDnsHosts()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
+ config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1";
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+ var dns = JsonUtils.Deserialize(JsonUtils.Serialize(cfg.dns))!;
+
+ dns.hosts.Should().NotBeNull();
+ dns.hosts!.Should().ContainKey("resolver.example");
+ JsonUtils.Serialize(dns.hosts!["resolver.example"]).Should().Contain("1.1.1.1");
+ }
+
+ [Fact]
+ public void GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsConfig()
+ {
+ var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
+ CoreConfigTestFactory.BindAppManagerConfig(config);
+
+ var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
+ var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
+ {
+ RawDnsItem = new DNSItem
+ {
+ Id = "dns-raw-1",
+ Remarks = "raw",
+ Enabled = true,
+ CoreType = ECoreType.Xray,
+ NormalDNS = "{\"servers\":[\"8.8.8.8\"],\"hosts\":{\"raw.example\":\"1.1.1.1\"}}",
+ DomainStrategy4Freedom = "UseIPv4",
+ }
+ };
+
+ var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
+
+ result.Success.Should().BeTrue();
+ var cfg = JsonUtils.Deserialize(result.Data!.ToString())!;
+ var dns = JsonUtils.Deserialize(JsonUtils.Serialize(cfg.dns))!;
+
+ JsonUtils.Serialize(dns.servers).Should().Contain("8.8.8.8");
+ dns.hosts.Should().NotBeNull();
+ dns.hosts!.Should().ContainKey("raw.example");
+ JsonUtils.Serialize(dns.hosts!["raw.example"]).Should().Contain("1.1.1.1");
+
+ var directOutbound = cfg.outbounds.FirstOrDefault(o => o.tag == Global.DirectTag && o.protocol == "freedom");
+ directOutbound.Should().NotBeNull();
+ directOutbound!.settings.domainStrategy.Should().Be("UseIPv4");
+ }
+}
diff --git a/v2rayN/ServiceLib.Tests/CoreConfigV2rayServiceTests.cs b/v2rayN/ServiceLib.Tests/CoreConfigV2rayServiceTests.cs
deleted file mode 100644
index 7d9ecdcb..00000000
--- a/v2rayN/ServiceLib.Tests/CoreConfigV2rayServiceTests.cs
+++ /dev/null
@@ -1,222 +0,0 @@
-using System.Text.Json.Nodes;
-using ServiceLib;
-using ServiceLib.Enums;
-using ServiceLib.Models;
-using ServiceLib.Services.CoreConfig;
-using Xunit;
-
-namespace ServiceLib.Tests;
-
-public class CoreConfigV2rayServiceTests
-{
- private const string SendThrough = "198.51.100.10";
-
- [Fact]
- public void GenerateClientConfigContent_OnlyAppliesSendThroughToRemoteProxyOutbounds()
- {
- var node = CreateProxyNode("proxy-1", "198.51.100.1", 443);
- var service = new CoreConfigV2rayService(CreateContext(node));
-
- var result = service.GenerateClientConfigContent();
-
- Assert.True(result.Success);
-
- var outbounds = GetOutbounds(result.Data?.ToString());
- var proxyOutbound = outbounds.Single(outbound => outbound["tag"]!.GetValue() == Global.ProxyTag);
- var directOutbound = outbounds.Single(outbound => outbound["tag"]!.GetValue() == Global.DirectTag);
- var blockOutbound = outbounds.Single(outbound => outbound["tag"]!.GetValue() == Global.BlockTag);
-
- Assert.Equal(SendThrough, proxyOutbound["sendThrough"]?.GetValue());
- Assert.Null(directOutbound["sendThrough"]);
- Assert.Null(blockOutbound["sendThrough"]);
- }
-
- [Fact]
- public void GenerateClientConfigContent_OnlyAppliesSendThroughToChainExitOutbounds()
- {
- var exitNode = CreateProxyNode("exit", "198.51.100.2", 443);
- var entryNode = CreateProxyNode("entry", "198.51.100.3", 443);
- var chainNode = CreateChainNode("chain", exitNode, entryNode);
-
- var service = new CoreConfigV2rayService(CreateContext(
- chainNode,
- allProxiesMap: new Dictionary
- {
- [exitNode.IndexId] = exitNode,
- [entryNode.IndexId] = entryNode,
- }));
-
- var result = service.GenerateClientConfigContent();
-
- Assert.True(result.Success);
-
- var outbounds = GetOutbounds(result.Data?.ToString())
- .Where(outbound => outbound["protocol"]?.GetValue() is not ("freedom" or "blackhole" or "dns"))
- .ToList();
-
- var sendThroughOutbounds = outbounds
- .Where(outbound => outbound["sendThrough"]?.GetValue() == SendThrough)
- .ToList();
- var chainedOutbounds = outbounds
- .Where(outbound => outbound["streamSettings"]?["sockopt"]?["dialerProxy"] is not null)
- .ToList();
-
- Assert.Single(sendThroughOutbounds);
- Assert.All(chainedOutbounds, outbound => Assert.Null(outbound["sendThrough"]));
- }
-
- [Fact]
- public void GenerateClientConfigContent_DoesNotApplySendThroughToTunRelayLoopbackOutbound()
- {
- var node = CreateProxyNode("proxy-1", "198.51.100.4", 443);
- var config = CreateConfig();
- config.TunModeItem.EnableLegacyProtect = false;
-
- var service = new CoreConfigV2rayService(CreateContext(
- node,
- config,
- isTunEnabled: true));
-
- var result = service.GenerateClientConfigContent();
-
- Assert.True(result.Success);
-
- var outbounds = GetOutbounds(result.Data?.ToString());
- Assert.DoesNotContain(outbounds, outbound => outbound["sendThrough"]?.GetValue() == SendThrough);
- }
-
- private static CoreConfigContext CreateContext(
- ProfileItem node,
- Config? config = null,
- Dictionary? allProxiesMap = null,
- bool isTunEnabled = false)
- {
- return new CoreConfigContext
- {
- Node = node,
- RunCoreType = ECoreType.Xray,
- AppConfig = config ?? CreateConfig(),
- AllProxiesMap = allProxiesMap ?? new(),
- SimpleDnsItem = new SimpleDNSItem(),
- IsTunEnabled = isTunEnabled,
- };
- }
-
- private static Config CreateConfig()
- {
- return new Config
- {
- IndexId = string.Empty,
- SubIndexId = string.Empty,
- CoreBasicItem = new()
- {
- LogEnabled = false,
- Loglevel = "warning",
- MuxEnabled = false,
- DefAllowInsecure = false,
- DefFingerprint = Global.Fingerprints.First(),
- DefUserAgent = string.Empty,
- SendThrough = SendThrough,
- EnableFragment = false,
- EnableCacheFile4Sbox = true,
- },
- TunModeItem = new()
- {
- EnableTun = false,
- AutoRoute = true,
- StrictRoute = true,
- Stack = string.Empty,
- Mtu = 9000,
- EnableIPv6Address = false,
- IcmpRouting = Global.TunIcmpRoutingPolicies.First(),
- EnableLegacyProtect = false,
- },
- KcpItem = new(),
- GrpcItem = new(),
- RoutingBasicItem = new()
- {
- DomainStrategy = Global.DomainStrategies.First(),
- DomainStrategy4Singbox = Global.DomainStrategies4Sbox.First(),
- RoutingIndexId = string.Empty,
- },
- GuiItem = new(),
- MsgUIItem = new(),
- UiItem = new()
- {
- CurrentLanguage = "en",
- CurrentFontFamily = string.Empty,
- MainColumnItem = [],
- WindowSizeItem = [],
- },
- ConstItem = new(),
- SpeedTestItem = new(),
- Mux4RayItem = new()
- {
- Concurrency = 8,
- XudpConcurrency = 8,
- XudpProxyUDP443 = "reject",
- },
- Mux4SboxItem = new()
- {
- Protocol = string.Empty,
- },
- HysteriaItem = new(),
- ClashUIItem = new()
- {
- ConnectionsColumnItem = [],
- },
- SystemProxyItem = new(),
- WebDavItem = new(),
- CheckUpdateItem = new(),
- Fragment4RayItem = null,
- Inbound = [new InItem
- {
- Protocol = EInboundProtocol.socks.ToString(),
- LocalPort = 10808,
- UdpEnabled = true,
- SniffingEnabled = true,
- RouteOnly = false,
- }],
- GlobalHotkeys = [],
- CoreTypeItem = [],
- SimpleDNSItem = new(),
- };
- }
-
- private static ProfileItem CreateProxyNode(string indexId, string address, int port)
- {
- return new ProfileItem
- {
- IndexId = indexId,
- Remarks = indexId,
- ConfigType = EConfigType.SOCKS,
- CoreType = ECoreType.Xray,
- Address = address,
- Port = port,
- };
- }
-
- private static ProfileItem CreateChainNode(string indexId, params ProfileItem[] nodes)
- {
- var chainNode = new ProfileItem
- {
- IndexId = indexId,
- Remarks = indexId,
- ConfigType = EConfigType.ProxyChain,
- CoreType = ECoreType.Xray,
- };
- chainNode.SetProtocolExtra(new ProtocolExtraItem
- {
- ChildItems = string.Join(',', nodes.Select(node => node.IndexId)),
- });
- return chainNode;
- }
-
- private static List GetOutbounds(string? json)
- {
- var root = JsonNode.Parse(json ?? throw new InvalidOperationException("Config JSON is missing"))?.AsObject()
- ?? throw new InvalidOperationException("Failed to parse config JSON");
- return root["outbounds"]?.AsArray().Select(node => node!.AsObject()).ToList()
- ?? throw new InvalidOperationException("Config JSON does not contain outbounds");
- }
-}
diff --git a/v2rayN/ServiceLib.Tests/Fmt/FmtHandlerTests.cs b/v2rayN/ServiceLib.Tests/Fmt/FmtHandlerTests.cs
new file mode 100644
index 00000000..56fb0f97
--- /dev/null
+++ b/v2rayN/ServiceLib.Tests/Fmt/FmtHandlerTests.cs
@@ -0,0 +1,173 @@
+using AwesomeAssertions;
+using ServiceLib.Handler.Fmt;
+using ServiceLib.Models;
+using ServiceLib.Enums;
+using Xunit;
+
+namespace ServiceLib.Tests.Fmt;
+
+public class FmtHandlerTests
+{
+ [Fact]
+ public void GetShareUriAndResolveConfig_Vmess_ShouldRoundTripBasicFields()
+ {
+ var source = CreateVmessProfile();
+
+ var resolved = ExportThenImport(source);
+
+ resolved.ConfigType.Should().Be(EConfigType.VMess);
+ resolved.Remarks.Should().Be(source.Remarks);
+ resolved.Address.Should().Be(source.Address);
+ resolved.Port.Should().Be(source.Port);
+ resolved.Password.Should().Be(source.Password);
+ resolved.GetProtocolExtra().AlterId.Should().Be(source.GetProtocolExtra().AlterId);
+ }
+
+ [Fact]
+ public void GetShareUriAndResolveConfig_Vless_ShouldRoundTripBasicFields()
+ {
+ var source = CreateVlessProfile();
+
+ var resolved = ExportThenImport(source);
+
+ resolved.ConfigType.Should().Be(EConfigType.VLESS);
+ resolved.Remarks.Should().Be(source.Remarks);
+ resolved.Address.Should().Be(source.Address);
+ resolved.Port.Should().Be(source.Port);
+ resolved.Password.Should().Be(source.Password);
+ resolved.GetProtocolExtra().VlessEncryption.Should().Be(Global.None);
+ }
+
+ [Fact]
+ public void GetShareUriAndResolveConfig_Shadowsocks_ShouldRoundTripBasicFields()
+ {
+ var source = CreateShadowsocksProfile();
+
+ var resolved = ExportThenImport(source);
+
+ resolved.ConfigType.Should().Be(EConfigType.Shadowsocks);
+ resolved.Remarks.Should().Be(source.Remarks);
+ resolved.Address.Should().Be(source.Address);
+ resolved.Port.Should().Be(source.Port);
+ resolved.Password.Should().Be(source.Password);
+ resolved.GetProtocolExtra().SsMethod.Should().Be(source.GetProtocolExtra().SsMethod);
+ }
+
+ [Fact]
+ public void GetShareUriAndResolveConfig_Socks_ShouldRoundTripBasicFields()
+ {
+ var source = CreateSocksProfile();
+
+ var resolved = ExportThenImport(source);
+
+ resolved.ConfigType.Should().Be(EConfigType.SOCKS);
+ resolved.Remarks.Should().Be(source.Remarks);
+ resolved.Address.Should().Be(source.Address);
+ resolved.Port.Should().Be(source.Port);
+ resolved.Username.Should().Be(source.Username);
+ resolved.Password.Should().Be(source.Password);
+ }
+
+ [Fact]
+ public void ResolveConfig_UnsupportedProtocol_ShouldReturnNull()
+ {
+ var resolved = FmtHandler.ResolveConfig("not-a-share-uri", out var msg);
+
+ resolved.Should().BeNull();
+ msg.Should().NotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public void GetShareUri_UnsupportedConfigType_ShouldReturnNull()
+ {
+ var item = new ProfileItem { ConfigType = EConfigType.PolicyGroup, Remarks = "group", };
+
+ var uri = FmtHandler.GetShareUri(item);
+
+ uri.Should().BeNull();
+ }
+
+ private static ProfileItem ExportThenImport(ProfileItem source)
+ {
+ var uri = FmtHandler.GetShareUri(source);
+
+ uri.Should().NotBeNullOrWhiteSpace();
+ (uri!.StartsWith(Global.ProtocolShares[source.ConfigType], StringComparison.OrdinalIgnoreCase)).Should()
+ .BeTrue();
+
+ var resolved = FmtHandler.ResolveConfig(uri, out var msg);
+
+ resolved.Should().NotBeNull($"uri: {uri}, msg: {msg}");
+ return resolved!;
+ }
+
+ private static ProfileItem CreateVmessProfile()
+ {
+ var item = new ProfileItem
+ {
+ ConfigType = EConfigType.VMess,
+ Remarks = "vmess demo",
+ Address = "example.com",
+ Port = 443,
+ Password = Guid.NewGuid().ToString(),
+ Network = nameof(ETransport.raw),
+ StreamSecurity = string.Empty,
+ };
+
+ item.SetProtocolExtra(new ProtocolExtraItem { AlterId = "0", VmessSecurity = Global.DefaultSecurity, });
+ item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
+
+ return item;
+ }
+
+ private static ProfileItem CreateVlessProfile()
+ {
+ var item = new ProfileItem
+ {
+ ConfigType = EConfigType.VLESS,
+ Remarks = "vless demo",
+ Address = "vless.example",
+ Port = 8443,
+ Password = Guid.NewGuid().ToString(),
+ Network = nameof(ETransport.raw),
+ StreamSecurity = string.Empty,
+ };
+
+ item.SetProtocolExtra(new ProtocolExtraItem { VlessEncryption = Global.None, });
+ item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
+
+ return item;
+ }
+
+ private static ProfileItem CreateShadowsocksProfile()
+ {
+ var item = new ProfileItem
+ {
+ ConfigType = EConfigType.Shadowsocks,
+ Remarks = "ss demo",
+ Address = "1.2.3.4",
+ Port = 8388,
+ Password = "pass123",
+ Network = nameof(ETransport.raw),
+ StreamSecurity = string.Empty,
+ };
+
+ item.SetProtocolExtra(new ProtocolExtraItem { SsMethod = "aes-128-gcm", });
+ item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
+
+ return item;
+ }
+
+ private static ProfileItem CreateSocksProfile()
+ {
+ return new ProfileItem
+ {
+ ConfigType = EConfigType.SOCKS,
+ Remarks = "socks demo",
+ Address = "127.0.0.1",
+ Port = 1080,
+ Username = "user",
+ Password = "pass",
+ };
+ }
+}
diff --git a/v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj b/v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj
index 4e1ed1a7..f86ca573 100644
--- a/v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj
+++ b/v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj
@@ -1,17 +1,19 @@
+ Exe
false
true
+
-
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
diff --git a/v2rayN/v2rayN.sln b/v2rayN/v2rayN.sln
index adf2cbf1..3c53a0f0 100644
--- a/v2rayN/v2rayN.sln
+++ b/v2rayN/v2rayN.sln
@@ -1,7 +1,7 @@
-
+
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.3.32811.315
+# Visual Studio Version 18
+VisualStudioVersion = 18.4.11620.152 stable
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "v2rayN", "v2rayN\v2rayN.csproj", "{6DE127CA-1763-4236-B297-D2EF9CB2EC9B}"
EndProject