Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
302 changes: 302 additions & 0 deletions v2rayN/ServiceLib.Tests/Handler/WorkflowHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
namespace ServiceLib.Tests.Handler;

public class WorkflowHandlerTests
{
[Test]
public async Task SaveSteps_ThenDeserialize_PreservesStepFields()
{
var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" };
List<WorkflowStep> steps =
[
new() { Action = EWorkflowAction.UpdateSubscriptions, SubId = "sub-1", BoolParameter = true },
new() { Action = EWorkflowAction.SortServers, Parameter = nameof(EServerColName.SpeedVal), BoolParameter = false },
new() { Action = EWorkflowAction.SystemProxy, Parameter = nameof(ESysProxyType.ForcedClear), Enabled = false },
];

WorkflowHandler.SaveSteps(workflow, steps);
var loaded = WorkflowHandler.DeserializeSteps(workflow);

await loaded.Count.Should().BeEqualTo(3);
await loaded[0].Action.Should().BeEqualTo(EWorkflowAction.UpdateSubscriptions);
await loaded[0].SubId.Should().BeEqualTo("sub-1");
await loaded[0].BoolParameter.Should().BeTrue();
await loaded[1].Parameter.Should().BeEqualTo(nameof(EServerColName.SpeedVal));
await loaded[2].Enabled.Should().BeFalse();
}

[Test]
public async Task SaveSteps_ShouldNotPersistEditorOnlyFields()
{
var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" };
List<WorkflowStep> steps =
[
new()
{
Action = EWorkflowAction.SortServers,
SubDisplay = "Group A",
Parameter = "DelayVal",
ActionOptions = ["SortServers"],
GroupOptions = ["Group A"],
},
];

WorkflowHandler.SaveSteps(workflow, steps);

await workflow.StepsJson.Contains("Group A").Should().BeFalse();
await workflow.StepsJson.Contains("SubDisplay").Should().BeFalse();
await workflow.StepsJson.Contains("ActionDisplay").Should().BeFalse();
await workflow.StepsJson.Contains("ActionOptions").Should().BeFalse();
await workflow.StepsJson.Contains("GroupOptions").Should().BeFalse();
await workflow.StepsJson.Contains("ParameterOptions").Should().BeFalse();
await workflow.StepsJson.Contains("ParameterText").Should().BeFalse();
}

[Test]
public async Task Parameters_ShouldOfferOnlyValuesTheRunnerAccepts()
{
// These actions take no parameter at all.
await WorkflowStepOptions.ParametersFor(EWorkflowAction.UpdateSubscriptions).Should().BeEmpty();
await WorkflowStepOptions.ParametersFor(EWorkflowAction.DeduplicateServers).Should().BeEmpty();
await WorkflowStepOptions.ParametersFor(EWorkflowAction.RemoveInvalidServers).Should().BeEmpty();

// The sort column placeholder is not sortable, so offering it would be a no-op.
await WorkflowStepOptions.ParametersFor(EWorkflowAction.SortServers)
.Should().NotContain(nameof(EServerColName.Def));
await WorkflowStepOptions.ParametersFor(EWorkflowAction.SortServers)
.Should().Contain(nameof(EServerColName.SpeedVal));

await WorkflowStepOptions.ParametersFor(EWorkflowAction.TestServers)
.Should().Contain(nameof(ESpeedActionType.Speedtest));
await WorkflowStepOptions.ParametersFor(EWorkflowAction.SystemProxy)
.Should().Contain(nameof(ESysProxyType.ForcedChange));

// Which server a group activates is a fixed choice, not free text.
await WorkflowStepOptions.ParametersFor(EWorkflowAction.ActivateServer)
.Should().Contain(nameof(EServerSelectType.Last));
}

[Test]
public async Task Normalize_ShouldReplaceParameterTheActionDoesNotAccept()
{
// A test type makes no sense for a sort, so it falls back to the default column.
await WorkflowStepOptions.Normalize(EWorkflowAction.SortServers, nameof(ESpeedActionType.Realping))
.Should().BeEqualTo(nameof(EServerColName.DelayVal));

// An action with no parameter must not keep a leftover one.
await WorkflowStepOptions.Normalize(EWorkflowAction.DeduplicateServers, nameof(EServerColName.DelayVal))
.Should().BeNull();

// A valid parameter is left alone.
await WorkflowStepOptions.Normalize(EWorkflowAction.TestServers, nameof(ESpeedActionType.Speedtest))
.Should().BeEqualTo(nameof(ESpeedActionType.Speedtest));
}

[Test]
public async Task Step_ShouldDropParameterWhenActionChanges()
{
var step = new WorkflowStep
{
Action = EWorkflowAction.SortServers,
Parameter = nameof(EServerColName.SpeedVal),
};

step.Action = EWorkflowAction.SystemProxy;

await step.Parameter.Should().BeEqualTo(nameof(ESysProxyType.ForcedClear));
await step.ParameterOptions.Should().Contain(nameof(ESysProxyType.ForcedChange));
await step.ParameterOptions.Should().NotContain(nameof(EServerColName.SpeedVal));
}

[Test]
public async Task Step_ShouldNotifyDependentCellsWhenActionChanges()
{
var step = new WorkflowStep();
var changed = new List<string?>();
step.PropertyChanged += (_, e) => changed.Add(e.PropertyName);

step.Action = EWorkflowAction.TestServers;

// The parameter dropdown binds to both of these, so both must refresh.
await changed.Should().Contain(nameof(WorkflowStep.ParameterOptions));
await changed.Should().Contain(nameof(WorkflowStep.ParameterText));
}

[Test]
[Arguments(null)]
[Arguments("")]
[Arguments("not json")]
[Arguments("{\"not\":\"a list\"}")]
public async Task DeserializeSteps_ShouldTolerateMissingOrMalformedJson(string? json)
{
var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly", StepsJson = json };

var loaded = WorkflowHandler.DeserializeSteps(workflow);

await loaded.Count.Should().BeEqualTo(0);
}

[Test]
public async Task DeserializeSteps_ShouldHandleNullWorkflow()
{
await WorkflowHandler.DeserializeSteps(null).Count.Should().BeEqualTo(0);
}

[Test]
public async Task Run_ShouldSkipDisabledStepsAndDispatchEveryAction()
{
var runtime = new RecordingRuntime();
var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" };
WorkflowHandler.SaveSteps(workflow,
[
new() { Action = EWorkflowAction.UpdateSubscriptions, BoolParameter = true },
new() { Action = EWorkflowAction.DeduplicateServers },
new() { Action = EWorkflowAction.SortServers, Parameter = nameof(EServerColName.SpeedVal), BoolParameter = false },
new() { Action = EWorkflowAction.TestServers, Parameter = nameof(ESpeedActionType.Speedtest) },
new() { Action = EWorkflowAction.RemoveInvalidServers, Enabled = false },
new() { Action = EWorkflowAction.ActivateServer, Parameter = nameof(EServerSelectType.First) },
new() { Action = EWorkflowAction.SystemProxy, Parameter = nameof(ESysProxyType.ForcedChange) },
]);

await WorkflowHandler.Run(workflow, runtime);

await runtime.Calls.Should().BeEquivalentTo(
[
// A step with no group means every group, so SubId stays null (not the selected group).
"update::True",
"dedup:",
"sort::SpeedVal:False",
"test::Speedtest",
"activate::First",
"sysproxy:ForcedChange",
]);
}

[Test]
public async Task Run_ShouldUseStepSubIdWhenSet()
{
var runtime = new RecordingRuntime();
var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" };
WorkflowHandler.SaveSteps(workflow,
[
new() { Action = EWorkflowAction.DeduplicateServers, SubId = "sub-9" },
]);

await WorkflowHandler.Run(workflow, runtime);

await runtime.Calls.Should().BeEquivalentTo(["dedup:sub-9"]);
}

[Test]
public async Task Run_ShouldFallBackToDefaultsForUnknownParameters()
{
var runtime = new RecordingRuntime();
var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" };
WorkflowHandler.SaveSteps(workflow,
[
new() { Action = EWorkflowAction.SortServers, Parameter = null },
new() { Action = EWorkflowAction.TestServers, Parameter = "not-a-test-type" },
new() { Action = EWorkflowAction.SystemProxy, Parameter = null },
new() { Action = EWorkflowAction.ActivateServer, Parameter = "not-a-select-type" },
]);

await WorkflowHandler.Run(workflow, runtime);

await runtime.Calls.Should().BeEquivalentTo(
[
$"sort::{nameof(EServerColName.DelayVal)}:False",
$"test::{nameof(ESpeedActionType.Realping)}",
$"sysproxy:{nameof(ESysProxyType.ForcedClear)}",
$"activate::{nameof(EServerSelectType.First)}",
]);
}

[Test]
public async Task Run_ShouldNotDispatchAnythingWhenEveryStepIsDisabled()
{
var runtime = new RecordingRuntime();
var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" };
WorkflowHandler.SaveSteps(workflow,
[
new() { Action = EWorkflowAction.UpdateSubscriptions, Enabled = false },
]);

await WorkflowHandler.Run(workflow, runtime);

await runtime.Calls.Count.Should().BeEqualTo(0);
}

/// <summary>
/// An all-groups step must reach the runtime as null, never as the currently selected
/// group, so it does not silently narrow to whatever group happens to be selected.
/// </summary>
[Test]
public async Task Run_ShouldKeepAllGroupsStepUnscopedRegardlessOfSelectedGroup()
{
var runtime = new RecordingRuntime();
var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" };
WorkflowHandler.SaveSteps(workflow,
[
new() { Action = EWorkflowAction.SortServers, SubId = null, Parameter = nameof(EServerColName.SpeedVal) },
new() { Action = EWorkflowAction.RemoveInvalidServers, SubId = null },
]);

await WorkflowHandler.Run(workflow, runtime);

await runtime.Calls.Should().BeEquivalentTo(
[
$"sort::{nameof(EServerColName.SpeedVal)}:False",
"remove-invalid:",
]);
}

/// <summary>
/// Records the calls the handler makes so the dispatch logic can be asserted without
/// touching the UI layer. This is an implementation of the runtime seam, not a mock.
/// </summary>
private sealed class RecordingRuntime : IWorkflowRuntime
{
public List<string> Calls { get; } = [];

public Task UpdateSubscriptions(string? subId, bool viaProxy)
{
Calls.Add($"update:{subId}:{viaProxy}");
return Task.CompletedTask;
}

public Task<int> DeduplicateServers(string? subId)
{
Calls.Add($"dedup:{subId}");
return Task.FromResult(0);
}

public Task SortServers(string? subId, string column, bool asc)
{
Calls.Add($"sort:{subId}:{column}:{asc}");
return Task.CompletedTask;
}

public Task TestServers(string? subId, ESpeedActionType actionType)
{
Calls.Add($"test:{subId}:{actionType}");
return Task.CompletedTask;
}

public Task<int> RemoveInvalidServers(string? subId)
{
Calls.Add($"remove-invalid:{subId}");
return Task.FromResult(0);
}

public Task ActivateServer(string? subId, EServerSelectType selectType)
{
Calls.Add($"activate:{subId}:{selectType}");
return Task.CompletedTask;
}

public Task SetSystemProxy(ESysProxyType type)
{
Calls.Add($"sysproxy:{type}");
return Task.CompletedTask;
}
}
}
39 changes: 39 additions & 0 deletions v2rayN/ServiceLib/Base/IWorkflowRuntime.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace ServiceLib.Base;

/// <summary>
/// The operations a workflow can invoke at runtime. Implemented by the UI layer
/// so that <see cref="Handler.WorkflowHandler"/> stays free of view models.
/// </summary>
public interface IWorkflowRuntime
{
/// <summary>Update one subscription, or every subscription when <paramref name="subId"/> is null.</summary>
Task UpdateSubscriptions(string? subId, bool viaProxy);

/// <summary>
/// Remove duplicate servers in a group, or in every group when <paramref name="subId"/> is null.
/// Returns the number of removed servers.
/// </summary>
Task<int> DeduplicateServers(string? subId);

/// <summary>Sort a group, or every group when <paramref name="subId"/> is null.</summary>
Task SortServers(string? subId, string column, bool asc);

/// <summary>Run a connectivity/speed test over a group, or every group when <paramref name="subId"/> is null.</summary>
Task TestServers(string? subId, ESpeedActionType actionType);

/// <summary>
/// Remove servers whose last test result was invalid, in a group or in every group when
/// <paramref name="subId"/> is null. Returns the number of removed servers.
/// </summary>
Task<int> RemoveInvalidServers(string? subId);

/// <summary>
/// Activate a server of a group, or of every group when <paramref name="subId"/> is null.
/// <paramref name="selectType"/> chooses the topmost or bottom row of the group's persisted
/// sort order.
/// </summary>
Task ActivateServer(string? subId, EServerSelectType selectType);

/// <summary>Set the system proxy mode.</summary>
Task SetSystemProxy(ESysProxyType type);
}
4 changes: 4 additions & 0 deletions v2rayN/ServiceLib/Common/JsonUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ public static string SerializeCamelCase<T>(T? obj)
[JsonSerializable(typeof(WebDavItem))]
[JsonSerializable(typeof(KeyEventItem))]
[JsonSerializable(typeof(SubItem))]
[JsonSerializable(typeof(WorkflowItem))]
[JsonSerializable(typeof(List<WorkflowItem>))]
[JsonSerializable(typeof(WorkflowStep))]
[JsonSerializable(typeof(List<WorkflowStep>))]
[JsonSerializable(typeof(SingboxConfig))]
[JsonSerializable(typeof(Dns4Sbox))]
[JsonSerializable(typeof(Inbound4Sbox))]
Expand Down
15 changes: 15 additions & 0 deletions v2rayN/ServiceLib/Enums/EServerSelectType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace ServiceLib.Enums;

/// <summary>
/// Which server of a group a workflow activates. The group uses the persisted sort
/// order (the same order the server list shows), so <see cref="First"/> is the topmost
/// row and lets a preceding sort decide the winner.
/// </summary>
public enum EServerSelectType
{
/// <summary>Topmost server of the group, i.e. the first row as displayed.</summary>
First = 1,

/// <summary>Bottom server of the group, i.e. the last row as displayed.</summary>
Last = 2,
}
28 changes: 28 additions & 0 deletions v2rayN/ServiceLib/Enums/EWorkflowAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace ServiceLib.Enums;

/// <summary>
/// A single reusable operation that can be chained in a workflow.
/// </summary>
public enum EWorkflowAction
{
/// <summary>Update one subscription, or every subscription when no sub is set.</summary>
UpdateSubscriptions = 0,

/// <summary>Remove duplicate servers in the current group.</summary>
DeduplicateServers = 1,

/// <summary>Sort servers in the current group by a column, ascending or descending.</summary>
SortServers = 2,

/// <summary>Run a connectivity/speed test over the current group.</summary>
TestServers = 3,

/// <summary>Remove servers whose last test result was invalid (timeout).</summary>
RemoveInvalidServers = 4,

/// <summary>Activate the best (first) server of the current group, as the server list orders it.</summary>
ActivateServer = 5,

/// <summary>Set or clear the system proxy mode.</summary>
SystemProxy = 6,
}
Loading