refactor: enforce one top-level type per file

This commit is contained in:
xiangning 2026-07-24 12:53:36 +08:00
parent 6ee0568e02
commit 86af47ad09
22 changed files with 359 additions and 320 deletions

View File

@ -400,11 +400,11 @@ git commit -m "refactor: classify backend business errors"
- Create `tests/BuildFlow.Workflow.Tests/Form/FormTestFixtureClassFixture.cs` from `FormTestFixture.cs`. - Create `tests/BuildFlow.Workflow.Tests/Form/FormTestFixtureClassFixture.cs` from `FormTestFixture.cs`.
- Create `tests/BuildFlow.Workflow.Tests/Notification/NoOpPlatformNotificationPublisher.cs` from `NotificationServiceTests.cs`. - Create `tests/BuildFlow.Workflow.Tests/Notification/NoOpPlatformNotificationPublisher.cs` from `NotificationServiceTests.cs`.
- [ ] **Step 1: Move declarations without changing behavior** - [x] **Step 1: Move declarations without changing behavior**
Preserve namespaces, modifiers, XML comments, JSON attributes, extension methods, and member order. Each original file retains its namesake type; each moved declaration gets a same-named file. Preserve namespaces, modifiers, XML comments, JSON attributes, extension methods, and member order. Each original file retains its namesake type; each moved declaration gets a same-named file.
- [ ] **Step 2: Run the file organization guard** - [x] **Step 2: Run the file organization guard**
```bash ```bash
dotnet test tests/BuildFlow.Architecture.Tests/BuildFlow.Architecture.Tests.csproj --no-restore --filter FullyQualifiedName~SourceFilesShouldContainAtMostOneTopLevelClassRecordOrEnum dotnet test tests/BuildFlow.Architecture.Tests/BuildFlow.Architecture.Tests.csproj --no-restore --filter FullyQualifiedName~SourceFilesShouldContainAtMostOneTopLevelClassRecordOrEnum
@ -412,7 +412,7 @@ dotnet test tests/BuildFlow.Architecture.Tests/BuildFlow.Architecture.Tests.cspr
Expected: PASS with zero violations under `src` and `tests`. Expected: PASS with zero violations under `src` and `tests`.
- [ ] **Step 3: Run affected projects** - [x] **Step 3: Run affected projects**
```bash ```bash
dotnet test tests/BuildFlow.Platform.Tests/BuildFlow.Platform.Tests.csproj --no-restore dotnet test tests/BuildFlow.Platform.Tests/BuildFlow.Platform.Tests.csproj --no-restore
@ -423,7 +423,7 @@ dotnet build src/BuildFlow.Host/BuildFlow.Host.csproj --no-restore
Expected: PASS, 0 warning, 0 error. Expected: PASS, 0 warning, 0 error.
- [ ] **Step 4: Commit** - [x] **Step 4: Commit**
```bash ```bash
git add src/BuildFlow.Host/Configuration src/modules/workflow/Domain/Engine tests git add src/BuildFlow.Host/Configuration src/modules/workflow/Domain/Engine tests

View File

@ -2,36 +2,6 @@ using System.Net.Http.Headers;
namespace BuildFlow.Host.Configuration; namespace BuildFlow.Host.Configuration;
/// <summary>
/// 轻量 Consul KV 配置 Provider(自实现,替代 Winton.Extensions.Configuration.Consul)。
/// 启动时用 Consul HTTP API 拉取 prefix 下所有 KV,扁平化注入 IConfiguration。
/// 支持 ReloadOnChange(后台 long-poll watch)。
///
/// 与 Winton 的区别:完全可控、有日志、不黑盒;用 HttpClient 直接调 /v1/kv/?recurse=true。
///
/// KV key 映射:Consul key 去掉 root 前缀后,剩余部分直接作为 IConfiguration key。
/// 如 Consul key "buildflow/Jwt:SigningKey",root="buildflow/",剩余 "Jwt:SigningKey" → IConfiguration key "Jwt:SigningKey"。
/// </summary>
internal sealed class ConsulConfigurationSource : IConfigurationSource
{
private readonly ConsulOptions _options;
public ConsulConfigurationSource(ConsulOptions options) => _options = options;
public IConfigurationProvider Build(IConfigurationBuilder builder) =>
new ConsulConfigurationProvider(_options);
}
internal sealed class ConsulOptions
{
public required string Host { get; init; } // 如 http://43.134.235.58:8520
public required string Token { get; init; } // ACL token
public required string KeyPrefix { get; init; } // 如 buildflow/
public bool Optional { get; init; } = true;
public bool ReloadOnChange { get; init; } = true;
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(30);
}
internal sealed class ConsulConfigurationProvider : ConfigurationProvider, IDisposable internal sealed class ConsulConfigurationProvider : ConfigurationProvider, IDisposable
{ {
private readonly ConsulOptions _options; private readonly ConsulOptions _options;
@ -160,14 +130,3 @@ internal sealed class ConsulConfigurationProvider : ConfigurationProvider, IDisp
_loggerFactory?.Dispose(); _loggerFactory?.Dispose();
} }
} }
// Consul KV API 响应项(recurse 接口)
internal sealed class ConsulKvItem
{
public int CreateIndex { get; set; }
public int ModifyIndex { get; set; }
public string Key { get; set; } = "";
public int LockIndex { get; set; }
public string? Value { get; set; }
public int Flags { get; set; }
}

View File

@ -0,0 +1,21 @@
namespace BuildFlow.Host.Configuration;
/// <summary>
/// 轻量 Consul KV 配置 Provider(自实现,替代 Winton.Extensions.Configuration.Consul)。
/// 启动时用 Consul HTTP API 拉取 prefix 下所有 KV,扁平化注入 IConfiguration。
/// 支持 ReloadOnChange(后台 long-poll watch)。
///
/// 与 Winton 的区别:完全可控、有日志、不黑盒;用 HttpClient 直接调 /v1/kv/?recurse=true。
///
/// KV key 映射:Consul key 去掉 root 前缀后,剩余部分直接作为 IConfiguration key。
/// 如 Consul key "buildflow/Jwt:SigningKey",root="buildflow/",剩余 "Jwt:SigningKey" → IConfiguration key "Jwt:SigningKey"。
/// </summary>
internal sealed class ConsulConfigurationSource : IConfigurationSource
{
private readonly ConsulOptions _options;
public ConsulConfigurationSource(ConsulOptions options) => _options = options;
public IConfigurationProvider Build(IConfigurationBuilder builder) =>
new ConsulConfigurationProvider(_options);
}

View File

@ -0,0 +1,12 @@
namespace BuildFlow.Host.Configuration;
// Consul KV API 响应项(recurse 接口)
internal sealed class ConsulKvItem
{
public int CreateIndex { get; set; }
public int ModifyIndex { get; set; }
public string Key { get; set; } = "";
public int LockIndex { get; set; }
public string? Value { get; set; }
public int Flags { get; set; }
}

View File

@ -0,0 +1,11 @@
namespace BuildFlow.Host.Configuration;
internal sealed class ConsulOptions
{
public required string Host { get; init; } // 如 http://43.134.235.58:8520
public required string Token { get; init; } // ACL token
public required string KeyPrefix { get; init; } // 如 buildflow/
public bool Optional { get; init; } = true;
public bool ReloadOnChange { get; init; } = true;
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(30);
}

View File

@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Workflow.Domain.Enums;
namespace Workflow.Domain.Engine;
/// <summary>
/// X6 边。source/target 用 { cell, port } 结构;几何信息(vertices/router/connector)放顶层;
/// 业务字段(edgeType/condition/order)放 data。
/// </summary>
public sealed class GraphEdge
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("shape")]
public string? Shape { get; set; }
[JsonPropertyName("source")]
public GraphEndpoint Source { get; set; } = new();
[JsonPropertyName("target")]
public GraphEndpoint Target { get; set; } = new();
/// <summary>业务数据:edgeType / condition / order。</summary>
[JsonPropertyName("data")]
public JsonElement? Data { get; set; }
[JsonPropertyName("label")]
public JsonElement? Label { get; set; }
[JsonPropertyName("vertices")]
public List<GraphPoint>? Vertices { get; set; }
[JsonPropertyName("router")]
public JsonElement? Router { get; set; }
[JsonPropertyName("connector")]
public JsonElement? Connector { get; set; }
/// <summary>边类型(Normal=0/Approved=1/Rejected=2)。缺省为 Normal。</summary>
public EdgeType GetEdgeType() => Data.TryGetInt32("edgeType") is { } v ? (EdgeType)v : EdgeType.Normal;
/// <summary>条件表达式(Condition/ExclusiveGateway 出边)。</summary>
public string? GetCondition() => Data.TryGetString("condition");
/// <summary>分支评估顺序(值小优先)。</summary>
public int GetOrder() => Data.TryGetInt32("order") ?? 0;
}

View File

@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace Workflow.Domain.Engine;
public sealed class GraphEndpoint
{
/// <summary>节点 id(对应 GraphNode.Id)。</summary>
[JsonPropertyName("cell")]
public string? Cell { get; set; }
/// <summary>端口 id(top/right/bottom/left)。</summary>
[JsonPropertyName("port")]
public string? Port { get; set; }
}

View File

@ -0,0 +1,25 @@
using System.Text.Json;
namespace Workflow.Domain.Engine;
/// <summary>
/// JsonElement? 安全取值扩展:统一处理 Nullable 解构与 ValueKind 校验,
/// 避免 WorkflowGraph 各 getter 重复 null 检查。
/// </summary>
internal static class GraphJsonExtensions
{
public static string? TryGetString(this JsonElement? element, string name) =>
element is { ValueKind: JsonValueKind.Object } el
&& el.TryGetProperty(name, out var p)
&& p.ValueKind == JsonValueKind.String
? p.GetString()
: null;
public static int? TryGetInt32(this JsonElement? element, string name) =>
element is { ValueKind: JsonValueKind.Object } el
&& el.TryGetProperty(name, out var p)
&& (p.ValueKind == JsonValueKind.Number || p.ValueKind == JsonValueKind.String)
&& p.TryGetInt32(out var n)
? n
: null;
}

View File

@ -0,0 +1,45 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Workflow.Domain.Engine;
/// <summary>
/// X6 节点。shape 决定渲染器并反推 NodeType(见 <see cref="WorkflowShapeMap"/>);
/// 业务配置统一进 data;x/y/width/height/ports 为几何与渲染信息。
/// </summary>
public sealed class GraphNode
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("shape")]
public string Shape { get; set; } = string.Empty;
[JsonPropertyName("x")]
public double X { get; set; }
[JsonPropertyName("y")]
public double Y { get; set; }
[JsonPropertyName("width")]
public double? Width { get; set; }
[JsonPropertyName("height")]
public double? Height { get; set; }
[JsonPropertyName("angle")]
public double? Angle { get; set; }
[JsonPropertyName("visible")]
public bool? Visible { get; set; }
/// <summary>业务数据(节点配置)。替代旧 WorkflowNode.Config 字符串字段。</summary>
[JsonPropertyName("data")]
public JsonElement? Data { get; set; }
/// <summary>显示名(对应旧 WorkflowNode.Name)。</summary>
public string? GetTitle() => Data.TryGetString("title");
[JsonPropertyName("ports")]
public JsonElement? Ports { get; set; }
}

View File

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Workflow.Domain.Engine;
public sealed class GraphPoint
{
[JsonPropertyName("x")]
public double X { get; set; }
[JsonPropertyName("y")]
public double Y { get; set; }
}

View File

@ -0,0 +1,26 @@
using System.Text.Json.Serialization;
namespace Workflow.Domain.Engine;
/// <summary>条件/并行分支容器中的一条分支。</summary>
public sealed class TreeBranch
{
/// <summary>分支 id,纯 Guid 字符串。</summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>分支显示名(如"条件 1"/"默认分支"/"分支 A")。</summary>
[JsonPropertyName("label")]
public string? Label { get; set; }
/// <summary>
/// 条件表达式(仅 ConditionBranch 使用;复用 ConditionEvaluator 语法,如 <c>金额 &gt; 5000</c>)。
/// 默认分支(else)置空,编译时 order 最大,引擎兜底命中。
/// </summary>
[JsonPropertyName("condition")]
public string? Condition { get; set; }
/// <summary>该分支内的首节点(分支内又是顺序链;null 表示空分支,编译时直通收口)。</summary>
[JsonPropertyName("child")]
public TreeNode? Child { get; set; }
}

View File

@ -0,0 +1,41 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Workflow.Domain.Enums;
namespace Workflow.Domain.Engine;
/// <summary>
/// 树节点。一条顺序链上的节点通过 <see cref="Child"/> 链接;
/// 容器型节点(条件/并行)通过 <see cref="Branches"/> 挂多条分支。
/// </summary>
public sealed class TreeNode
{
/// <summary>节点 id,必须是纯 Guid 字符串(引擎 ParseGuid 硬约束)。</summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>节点类型。决定渲染卡片与配置面板。</summary>
[JsonPropertyName("type")]
[JsonConverter(typeof(JsonStringEnumConverter))]
public TreeNodeTypeEnum Type { get; set; }
/// <summary>
/// 节点业务配置(自由对象)。沿用引擎 node.data 的字段约定:
/// 审批节点:approvalMode/assigneeRules/timeoutMinutes/fieldPermissions 等;
/// 抄送节点:recipients;分支内首节点的 condition 在 <see cref="TreeBranch"/> 上。
/// </summary>
[JsonPropertyName("config")]
public JsonElement? Config { get; set; }
/// <summary>显示标题(冗余于 config.title,便于编辑器直接读取)。</summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>顺序链上的下一个节点(null 表示链尾,编译器自动接 End)。</summary>
[JsonPropertyName("child")]
public TreeNode? Child { get; set; }
/// <summary>分支集合(仅 ConditionBranch / ParallelBranch 容器节点使用)。</summary>
[JsonPropertyName("branches")]
public List<TreeBranch>? Branches { get; set; }
}

View File

@ -20,130 +20,3 @@ public sealed class WorkflowGraph
/// <summary>空图工厂(容错用)。</summary> /// <summary>空图工厂(容错用)。</summary>
public static WorkflowGraph Empty() => new(); public static WorkflowGraph Empty() => new();
} }
/// <summary>
/// X6 节点。shape 决定渲染器并反推 NodeType(见 <see cref="WorkflowShapeMap"/>);
/// 业务配置统一进 data;x/y/width/height/ports 为几何与渲染信息。
/// </summary>
public sealed class GraphNode
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("shape")]
public string Shape { get; set; } = string.Empty;
[JsonPropertyName("x")]
public double X { get; set; }
[JsonPropertyName("y")]
public double Y { get; set; }
[JsonPropertyName("width")]
public double? Width { get; set; }
[JsonPropertyName("height")]
public double? Height { get; set; }
[JsonPropertyName("angle")]
public double? Angle { get; set; }
[JsonPropertyName("visible")]
public bool? Visible { get; set; }
/// <summary>业务数据(节点配置)。替代旧 WorkflowNode.Config 字符串字段。</summary>
[JsonPropertyName("data")]
public JsonElement? Data { get; set; }
/// <summary>显示名(对应旧 WorkflowNode.Name)。</summary>
public string? GetTitle() => Data.TryGetString("title");
[JsonPropertyName("ports")]
public JsonElement? Ports { get; set; }
}
/// <summary>
/// X6 边。source/target 用 { cell, port } 结构;几何信息(vertices/router/connector)放顶层;
/// 业务字段(edgeType/condition/order)放 data。
/// </summary>
public sealed class GraphEdge
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("shape")]
public string? Shape { get; set; }
[JsonPropertyName("source")]
public GraphEndpoint Source { get; set; } = new();
[JsonPropertyName("target")]
public GraphEndpoint Target { get; set; } = new();
/// <summary>业务数据:edgeType / condition / order。</summary>
[JsonPropertyName("data")]
public JsonElement? Data { get; set; }
[JsonPropertyName("label")]
public JsonElement? Label { get; set; }
[JsonPropertyName("vertices")]
public List<GraphPoint>? Vertices { get; set; }
[JsonPropertyName("router")]
public JsonElement? Router { get; set; }
[JsonPropertyName("connector")]
public JsonElement? Connector { get; set; }
/// <summary>边类型(Normal=0/Approved=1/Rejected=2)。缺省为 Normal。</summary>
public EdgeType GetEdgeType() => Data.TryGetInt32("edgeType") is { } v ? (EdgeType)v : EdgeType.Normal;
/// <summary>条件表达式(Condition/ExclusiveGateway 出边)。</summary>
public string? GetCondition() => Data.TryGetString("condition");
/// <summary>分支评估顺序(值小优先)。</summary>
public int GetOrder() => Data.TryGetInt32("order") ?? 0;
}
public sealed class GraphEndpoint
{
/// <summary>节点 id(对应 GraphNode.Id)。</summary>
[JsonPropertyName("cell")]
public string? Cell { get; set; }
/// <summary>端口 id(top/right/bottom/left)。</summary>
[JsonPropertyName("port")]
public string? Port { get; set; }
}
public sealed class GraphPoint
{
[JsonPropertyName("x")]
public double X { get; set; }
[JsonPropertyName("y")]
public double Y { get; set; }
}
/// <summary>
/// JsonElement? 安全取值扩展:统一处理 Nullable 解构与 ValueKind 校验,
/// 避免 WorkflowGraph 各 getter 重复 null 检查。
/// </summary>
internal static class GraphJsonExtensions
{
public static string? TryGetString(this JsonElement? element, string name) =>
element is { ValueKind: JsonValueKind.Object } el
&& el.TryGetProperty(name, out var p)
&& p.ValueKind == JsonValueKind.String
? p.GetString()
: null;
public static int? TryGetInt32(this JsonElement? element, string name) =>
element is { ValueKind: JsonValueKind.Object } el
&& el.TryGetProperty(name, out var p)
&& (p.ValueKind == JsonValueKind.Number || p.ValueKind == JsonValueKind.String)
&& p.TryGetInt32(out var n)
? n
: null;
}

View File

@ -27,62 +27,3 @@ public sealed class WorkflowTree
/// <summary>空树工厂:用于缺省/容错。</summary> /// <summary>空树工厂:用于缺省/容错。</summary>
public static WorkflowTree Empty() => new(); public static WorkflowTree Empty() => new();
} }
/// <summary>
/// 树节点。一条顺序链上的节点通过 <see cref="Child"/> 链接;
/// 容器型节点(条件/并行)通过 <see cref="Branches"/> 挂多条分支。
/// </summary>
public sealed class TreeNode
{
/// <summary>节点 id,必须是纯 Guid 字符串(引擎 ParseGuid 硬约束)。</summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>节点类型。决定渲染卡片与配置面板。</summary>
[JsonPropertyName("type")]
[JsonConverter(typeof(JsonStringEnumConverter))]
public TreeNodeTypeEnum Type { get; set; }
/// <summary>
/// 节点业务配置(自由对象)。沿用引擎 node.data 的字段约定:
/// 审批节点:approvalMode/assigneeRules/timeoutMinutes/fieldPermissions 等;
/// 抄送节点:recipients;分支内首节点的 condition 在 <see cref="TreeBranch"/> 上。
/// </summary>
[JsonPropertyName("config")]
public JsonElement? Config { get; set; }
/// <summary>显示标题(冗余于 config.title,便于编辑器直接读取)。</summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>顺序链上的下一个节点(null 表示链尾,编译器自动接 End)。</summary>
[JsonPropertyName("child")]
public TreeNode? Child { get; set; }
/// <summary>分支集合(仅 ConditionBranch / ParallelBranch 容器节点使用)。</summary>
[JsonPropertyName("branches")]
public List<TreeBranch>? Branches { get; set; }
}
/// <summary>条件/并行分支容器中的一条分支。</summary>
public sealed class TreeBranch
{
/// <summary>分支 id,纯 Guid 字符串。</summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>分支显示名(如"条件 1"/"默认分支"/"分支 A")。</summary>
[JsonPropertyName("label")]
public string? Label { get; set; }
/// <summary>
/// 条件表达式(仅 ConditionBranch 使用;复用 ConditionEvaluator 语法,如 <c>金额 &gt; 5000</c>)。
/// 默认分支(else)置空,编译时 order 最大,引擎兜底命中。
/// </summary>
[JsonPropertyName("condition")]
public string? Condition { get; set; }
/// <summary>该分支内的首节点(分支内又是顺序链;null 表示空分支,编译时直通收口)。</summary>
[JsonPropertyName("child")]
public TreeNode? Child { get; set; }
}

View File

@ -0,0 +1,64 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
namespace BuildFlow.Platform.Tests;
/// 极简字典实现的 IConfiguration,仅供测试使用。
/// 完整实现 GetSection/GetChildren,让 GetValue&lt;T&gt; 扩展方法(走 ConfigurationBinder)能正确工作。
/// 注意:ConfigurationBinder.GetValue 内部调用 GetSection(key).Value,故必须返回有效 section。
internal sealed class DictionaryConfiguration : IConfiguration
{
private readonly Dictionary<string, string?> _values = [];
public string? this[string key]
{
get => _values.TryGetValue(key, out var v) ? v : null;
set => _values[key] = value;
}
// 支持集合初始化器语法:new DictionaryConfiguration { ["k"] = "v" }
public void Add(string key, string? value) => _values[key] = value;
IEnumerable<IConfigurationSection> IConfiguration.GetChildren() =>
_values.Select(kv => (IConfigurationSection)new LeafSection(kv.Key, kv.Value)).ToList();
IChangeToken IConfiguration.GetReloadToken() => EmptyChangeToken.Instance;
IConfigurationSection IConfiguration.GetSection(string key) =>
new LeafSection(key, _values.TryGetValue(key, out var v) ? v : null);
private sealed class LeafSection(string key, string? value) : IConfigurationSection
{
public string Key => key;
public string Path => key;
public string? Value { get; set; } = value;
string? IConfiguration.this[string childKey]
{
get => null;
set { }
}
IConfigurationSection IConfiguration.GetSection(string childKey) =>
new LeafSection($"{key}:{childKey}", null);
IEnumerable<IConfigurationSection> IConfiguration.GetChildren() => [];
IChangeToken IConfiguration.GetReloadToken() => EmptyChangeToken.Instance;
}
/// <summary>永不变更的占位 IChangeToken,避免引入额外包依赖。</summary>
private sealed class EmptyChangeToken : IChangeToken
{
public static readonly EmptyChangeToken Instance = new();
public bool ActiveChangeCallbacks => false;
public bool HasChanged => false;
public IDisposable RegisterChangeCallback(Action<object> callback, object? state) =>
new NoopDisposable();
private sealed class NoopDisposable : IDisposable
{
public void Dispose() { }
}
}
}

View File

@ -3,7 +3,6 @@ using BuildFlow.Platform.Infrastructure.Persistence;
using BuildFlow.Shared.Domain.Common; using BuildFlow.Shared.Domain.Common;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace BuildFlow.Platform.Tests; namespace BuildFlow.Platform.Tests;
@ -106,63 +105,3 @@ internal static class PlatformTestDb
return user; return user;
} }
} }
/// 极简字典实现的 IConfiguration,仅供测试使用。
/// 完整实现 GetSection/GetChildren,让 GetValue&lt;T&gt; 扩展方法(走 ConfigurationBinder)能正确工作。
/// 注意:ConfigurationBinder.GetValue 内部调用 GetSection(key).Value,故必须返回有效 section。
internal sealed class DictionaryConfiguration : IConfiguration
{
private readonly Dictionary<string, string?> _values = [];
public string? this[string key]
{
get => _values.TryGetValue(key, out var v) ? v : null;
set => _values[key] = value;
}
// 支持集合初始化器语法:new DictionaryConfiguration { ["k"] = "v" }
public void Add(string key, string? value) => _values[key] = value;
IEnumerable<IConfigurationSection> IConfiguration.GetChildren() =>
_values.Select(kv => (IConfigurationSection)new LeafSection(kv.Key, kv.Value)).ToList();
IChangeToken IConfiguration.GetReloadToken() => EmptyChangeToken.Instance;
IConfigurationSection IConfiguration.GetSection(string key) =>
new LeafSection(key, _values.TryGetValue(key, out var v) ? v : null);
private sealed class LeafSection(string key, string? value) : IConfigurationSection
{
public string Key => key;
public string Path => key;
public string? Value { get; set; } = value;
string? IConfiguration.this[string childKey]
{
get => null;
set { }
}
IConfigurationSection IConfiguration.GetSection(string childKey) =>
new LeafSection($"{key}:{childKey}", null);
IEnumerable<IConfigurationSection> IConfiguration.GetChildren() => [];
IChangeToken IConfiguration.GetReloadToken() => EmptyChangeToken.Instance;
}
/// <summary>永不变更的占位 IChangeToken,避免引入额外包依赖。</summary>
private sealed class EmptyChangeToken : IChangeToken
{
public static readonly EmptyChangeToken Instance = new();
public bool ActiveChangeCallbacks => false;
public bool HasChanged => false;
public IDisposable RegisterChangeCallback(Action<object> callback, object? state) =>
new NoopDisposable();
private sealed class NoopDisposable : IDisposable
{
public void Dispose() { }
}
}
}

View File

@ -24,13 +24,3 @@ public sealed class StubDocumentExtractor : IDocumentExtractor
SourcePath = filePath SourcePath = filePath
}); });
} }
/// <summary>
/// 工厂:统一返回 StubDocumentExtractor。
/// </summary>
public sealed class StubDocumentExtractorFactory : IDocumentExtractorFactory
{
public StubDocumentExtractor InnerExtractor { get; } = new();
public IDocumentExtractor GetExtractor(string fileNameOrExtension) => InnerExtractor;
}

View File

@ -0,0 +1,13 @@
using BuildFlow.Domain.Interfaces;
namespace BuildFlow.Rag.Tests.Stub;
/// <summary>
/// 工厂:统一返回 StubDocumentExtractor。
/// </summary>
public sealed class StubDocumentExtractorFactory : IDocumentExtractorFactory
{
public StubDocumentExtractor InnerExtractor { get; } = new();
public IDocumentExtractor GetExtractor(string fileNameOrExtension) => InnerExtractor;
}

View File

@ -43,8 +43,3 @@ public class FormTestFixture : IDisposable
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
} }
/// <summary>
/// xUnit ClassFixture,用于在测试类级别共享 Fixture 实例。
/// </summary>
public class FormTestFixtureClassFixture : FormTestFixture;

View File

@ -0,0 +1,6 @@
namespace BuildFlow.Workflow.Tests.Form;
/// <summary>
/// xUnit ClassFixture,用于在测试类级别共享 Fixture 实例。
/// </summary>
public class FormTestFixtureClassFixture : FormTestFixture;

View File

@ -0,0 +1,16 @@
using Workflow.Domain.Common;
namespace BuildFlow.Workflow.Tests.Notification;
/// <summary>
/// 测试用 no-op 平台通知发布器:转发逻辑由 Host 层实现,单元测试无需真实推送,
/// 此实现空跑返回 0,避免 NotificationService 构造依赖阻断。
/// </summary>
internal sealed class NoOpPlatformNotificationPublisher : IPlatformNotificationPublisher
{
public Task<int> PublishAsync(
string type, string title, string content, string source,
string? relatedId, string? relatedType,
IEnumerable<Guid> recipientUserIds, CancellationToken ct = default)
=> Task.FromResult(0);
}

View File

@ -255,16 +255,3 @@ public class NotificationServiceTests
(await db.WebhookDeliveries.CountAsync()).Should().Be(0); (await db.WebhookDeliveries.CountAsync()).Should().Be(0);
} }
} }
/// <summary>
/// 测试用 no-op 平台通知发布器:转发逻辑由 Host 层实现,单元测试无需真实推送,
/// 此实现空跑返回 0,避免 NotificationService 构造依赖阻断。
/// </summary>
file sealed class NoOpPlatformNotificationPublisher : IPlatformNotificationPublisher
{
public Task<int> PublishAsync(
string type, string title, string content, string source,
string? relatedId, string? relatedType,
IEnumerable<Guid> recipientUserIds, CancellationToken ct = default)
=> Task.FromResult(0);
}