From 74eb99f67d19d2d3f3c002d506dcf3a36b07677c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B8=AD=E9=B8=AD=E3=80=8C=E3=82=AB=E3=83=A2=E3=80=8D?= <89643991+DuckDuckStudio@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:55:13 +0800 Subject: [PATCH 1/2] =?UTF-8?q?tests:=20=E6=B7=BB=E5=8A=A0=20xUnit=20?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=E5=92=8C=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 6 + .github/workflows/xUnit.yaml | 35 +++ global.json | 5 + src/PinAction.slnx | 1 + src/PinAction.tests/PinAction.tests.csproj | 27 ++ src/PinAction.tests/UnitTests.cs | 298 +++++++++++++++++++++ src/PinAction.tests/xunit.runner.json | 3 + src/PinAction/PinAction.csproj | 4 + src/PinAction/Program.cs | 10 +- 9 files changed, 384 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/xUnit.yaml create mode 100644 global.json create mode 100644 src/PinAction.tests/PinAction.tests.csproj create mode 100644 src/PinAction.tests/UnitTests.cs create mode 100644 src/PinAction.tests/xunit.runner.json diff --git a/.editorconfig b/.editorconfig index 69fc164..9015a8b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,6 @@ +[*] +indent_style = space + [*.cs] # 使用显式类型 csharp_style_var_for_built_in_types = false:warning @@ -6,3 +9,6 @@ csharp_style_var_elsewhere = false:warning # 多余限定符 resharper_redundant_name_qualifier_highlighting = hint + +[*.{csproj,slnx}] +indent_size = 2 diff --git a/.github/workflows/xUnit.yaml b/.github/workflows/xUnit.yaml new file mode 100644 index 0000000..89865f5 --- /dev/null +++ b/.github/workflows/xUnit.yaml @@ -0,0 +1,35 @@ +name: xUnit 单元测试 + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: {} + +jobs: + tests: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + + steps: + - name: 检出代码 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: 设置 .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.x + + - name: 运行测试 + run: dotnet test "src/" diff --git a/global.json b/global.json new file mode 100644 index 0000000..8f73781 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} \ No newline at end of file diff --git a/src/PinAction.slnx b/src/PinAction.slnx index f4dde52..ae774cc 100644 --- a/src/PinAction.slnx +++ b/src/PinAction.slnx @@ -1,3 +1,4 @@ + diff --git a/src/PinAction.tests/PinAction.tests.csproj b/src/PinAction.tests/PinAction.tests.csproj new file mode 100644 index 0000000..4fdf1d4 --- /dev/null +++ b/src/PinAction.tests/PinAction.tests.csproj @@ -0,0 +1,27 @@ + + + + enable + enable + Exe + net10.0 + + + + + + + + + + + + + + + + + + + + diff --git a/src/PinAction.tests/UnitTests.cs b/src/PinAction.tests/UnitTests.cs new file mode 100644 index 0000000..0e20326 --- /dev/null +++ b/src/PinAction.tests/UnitTests.cs @@ -0,0 +1,298 @@ +using Moq; +using Octokit; + +namespace PinAction.Tests +{ + /// + /// 定义测试集合,禁用并行化以避免对共享资源的冲突。 + /// + [CollectionDefinition(nameof(ProgramTestsCollection), DisableParallelization = true)] + public sealed class ProgramTestsCollection; + + /// + /// 单元测试类。 + /// + [Collection(nameof(ProgramTestsCollection))] + public sealed class UnitTests : IDisposable + { + /// + /// 用于测试的固定提交 SHA 值。 + /// + private const string CommitSha = "0123456789abcdef0123456789abcdef01234567"; + + /// + /// 用于测试的临时目录路径。 + /// + private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory().FullName; + + /// + /// 原始的 GitHub 客户端实例,用于在测试完成后恢复。 + /// + private readonly IGitHubClient _originalGitHubClient = Program.GitHubClient; + + /// + /// 用于测试的 GitHub 客户端模拟实例。 + /// + private readonly Mock _githubClient = new(MockBehavior.Strict); + + /// + /// 用于测试的 Git 数据库客户端模拟实例。 + /// + private readonly Mock _gitDatabase = new(MockBehavior.Strict); + + /// + /// 用于测试的 Git 标签客户端模拟实例。 + /// + private readonly Mock _tags = new(MockBehavior.Strict); + + /// + /// 用于测试的引用客户端模拟实例。 + /// + private readonly Mock _references = new(MockBehavior.Strict); + + /// + /// 用于测试的仓库客户端模拟实例。 + /// + private readonly Mock _repositories = new(MockBehavior.Strict); + + /// + /// 初始化单元测试类的构造函数。 + /// + public UnitTests() + { + // 设置 GitHub 客户端的模拟行为 + _gitDatabase.Setup(client => client.Reference).Returns(_references.Object); + _gitDatabase.Setup(client => client.Tag).Returns(_tags.Object); + _githubClient.Setup(client => client.Git).Returns(_gitDatabase.Object); + _githubClient.Setup(client => client.Repository).Returns(_repositories.Object); + + Program.PinedActions.Clear(); + Program.GitHubClient = _githubClient.Object; + } + + /// + /// 测试 CompareVersionStrings 方法是否按自然顺序比较版本字符串。 + /// + [Fact] + public void CompareVersionStrings_OrdersNumericSegmentsNaturally() + { + Assert.True(Program.CompareVersionStrings("v1.9.0", "v1.10.0") < 0); + Assert.True(Program.CompareVersionStrings("v2.0", "v1.99") > 0); + Assert.Equal(0, Program.CompareVersionStrings("release-1.2", "v1.2.0")); + } + + /// + /// 测试 ResolveRefinedTag 方法是否返回与提交匹配的最高版本标签。 + /// + [Fact] + public void ResolveRefinedTag_ReturnsHighestVersionForMatchingCommit() + { + _repositories + .Setup(client => client.GetAllTags("owner", "repo")) + .Returns(Task.FromResult>( + [ + CreateRepositoryTag("v1.9.0", CommitSha), + CreateRepositoryTag("v1.10.0", CommitSha), + CreateRepositoryTag("v99.0.0", "ffffffffffffffffffffffffffffffffffffffff") + ])); + + string result = Program.ResolveRefinedTag("owner/repo", "main", CommitSha); + + Assert.Equal("v1.10.0", result); + _repositories.Verify(client => client.GetAllTags("owner", "repo"), Times.Once); + } + + /// + /// 测试 ResolveRefinedTag 方法是否在提交没有匹配标签时返回原始标签。 + /// + [Fact] + public void ResolveRefinedTag_ReturnsOriginalTagWhenCommitHasNoMatchingTag() + { + _repositories + .Setup(client => client.GetAllTags("owner", "repo")) + .Returns(Task.FromResult>( + [ + CreateRepositoryTag("v1.0.0", "ffffffffffffffffffffffffffffffffffffffff") + ])); + + string result = Program.ResolveRefinedTag("owner/repo", "main", CommitSha); + + Assert.Equal("main", result); + } + + /// + /// 测试 PinActionHash 方法是否正确固定引用并保留内联注释。 + /// + [Fact] + public void PinActionHash_PinsReferencesAndPreservesInlineComments() + { + string path = Path.Combine(_temporaryDirectory, "workflow.yml"); + File.WriteAllLines(path, + [ + "name: test", + " uses: owner/repo@v1 # keep this comment", + " uses: owner/repo@v1", + " run: echo uses: not-an-action" + ]); + + _references + .Setup(client => client.Get("owner", "repo", "tags/v1")) + .Returns(Task.FromResult(CreateReference(CommitSha))); + _repositories + .Setup(client => client.GetAllTags("owner", "repo")) + .Returns(Task.FromResult>( + [ + CreateRepositoryTag("v1.2.0", CommitSha) + ])); + + bool succeeded = Program.PinActionHash(path); + + Assert.True(succeeded); + string[] expectedLines = + [ + "name: test", + $" uses: owner/repo@{CommitSha} # v1.2.0 keep this comment", // # 前面的空格数应保持原样 + $" uses: owner/repo@{CommitSha} # v1.2.0", // 默认两个空格 + " run: echo uses: not-an-action" + ]; + Assert.Equal(expectedLines, File.ReadAllLines(path)); + _references.Verify(client => client.Get("owner", "repo", "tags/v1"), Times.Once); + _tags.Verify(client => client.Get( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + _repositories.Verify(client => client.GetAllTags("owner", "repo"), Times.Once); + } + + /// + /// 测试 PinActionHash 方法是否将附注标签对象解析为其目标提交哈希。 + /// + [Fact] + public void PinActionHash_ResolvesAnnotatedTagToCommitHash() + { + const string tagObjectSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + string path = Path.Combine(_temporaryDirectory, "workflow.yml"); + File.WriteAllText(path, " uses: owner/repo@v1"); + + _references + .Setup(client => client.Get("owner", "repo", "tags/v1")) + .Returns(Task.FromResult(CreateReference(tagObjectSha, TaggedType.Tag))); + _tags + .Setup(client => client.Get("owner", "repo", tagObjectSha)) + .Returns(Task.FromResult(CreateTagObject(CommitSha, TaggedType.Commit))); + _repositories + .Setup(client => client.GetAllTags("owner", "repo")) + .Returns(Task.FromResult>([])); + + bool succeeded = Program.PinActionHash(path); + + Assert.True(succeeded); + Assert.Equal($" uses: owner/repo@{CommitSha} # v1", File.ReadAllText(path).TrimEnd('\r', '\n')); + _tags.Verify(client => client.Get("owner", "repo", tagObjectSha), Times.Once); + } + + /// + /// 测试 PinActionHash 方法是否跳过最终目标不是提交的附注标签。 + /// + [Fact] + public void PinActionHash_SkipsAnnotatedTagWhenResolvedTargetIsNotCommit() + { + const string tagObjectSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const string treeSha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + string path = Path.Combine(_temporaryDirectory, "workflow.yml"); + const string originalLine = " uses: owner/repo@v1"; + File.WriteAllText(path, originalLine); + + _references + .Setup(client => client.Get("owner", "repo", "tags/v1")) + .Returns(Task.FromResult(CreateReference(tagObjectSha, TaggedType.Tag))); + _tags + .Setup(client => client.Get("owner", "repo", tagObjectSha)) + .Returns(Task.FromResult(CreateTagObject(treeSha, TaggedType.Tree))); + + bool result = Program.PinActionHash(path); + + Assert.Equal(originalLine, File.ReadAllText(path).TrimEnd('\r', '\n')); + Assert.False(Program.PinedActions.ContainsKey("owner/repo@v1")); + Assert.False(result); + _tags.Verify(client => client.Get("owner", "repo", tagObjectSha), Times.Once); + _repositories.Verify(client => client.GetAllTags( + It.IsAny(), + It.IsAny()), Times.Never); + } + + /// + /// 测试 PinActionHash 方法是否跳过已固定的和无效的仓库引用。 + /// + [Fact] + public void PinActionHash_SkipsAlreadyPinnedAndInvalidRepositoryReferences() + { + string path = Path.Combine(_temporaryDirectory, "workflow.yml"); + string[] originalLines = + [ + $" uses: owner/repo@{CommitSha}", + " uses: local-action@v1", + " uses: owner/repo/path@v1" + ]; + File.WriteAllLines(path, originalLines); + + bool succeeded = Program.PinActionHash(path); + + Assert.True(succeeded); + Assert.Equal(originalLines, File.ReadAllLines(path)); + _references.Verify(client => client.Get( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + _repositories.Verify(client => client.GetAllTags( + It.IsAny(), + It.IsAny()), Times.Never); + } + + /// + /// 还原单元测试使用的资源。 + /// + public void Dispose() + { + Program.PinedActions.Clear(); + Program.GitHubClient = _originalGitHubClient; + Directory.Delete(_temporaryDirectory, recursive: true); + } + + /// + /// 创建一个引用实例。 + /// + /// 提交的 SHA。 + /// 目标对象的类型。 + /// 引用实例。 + private static Reference CreateReference(string sha, TaggedType type = TaggedType.Commit) + { + TagObject target = new("", "", "", "", sha, null, null, type); + return new Reference("refs/tags/v1", "", "", target); + } + + /// + /// 创建一个附注标签对象实例。 + /// + /// 目标对象的 SHA。 + /// 目标对象的类型。 + /// 附注标签对象实例。 + private static GitTag CreateTagObject(string sha, TaggedType type) + { + TagObject target = new("", "", "", "", sha, null, null, type); + return new GitTag("", "", "", "", "", null, null, "", "", null, target, null); + } + + /// + /// 创建一个仓库标签实例。 + /// + /// 标签的名称。 + /// 提交的 SHA。 + /// 仓库标签实例。 + private static RepositoryTag CreateRepositoryTag(string name, string sha) + { + GitReference commit = new("", "", "", "", sha, null!, null!); + return new RepositoryTag(name, "", commit, "", ""); + } + } +} diff --git a/src/PinAction.tests/xunit.runner.json b/src/PinAction.tests/xunit.runner.json new file mode 100644 index 0000000..86c7ea0 --- /dev/null +++ b/src/PinAction.tests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" +} diff --git a/src/PinAction/PinAction.csproj b/src/PinAction/PinAction.csproj index 63b343b..9f35c49 100644 --- a/src/PinAction/PinAction.csproj +++ b/src/PinAction/PinAction.csproj @@ -23,6 +23,10 @@ + + + + / diff --git a/src/PinAction/Program.cs b/src/PinAction/Program.cs index d45f42b..2662bef 100644 --- a/src/PinAction/Program.cs +++ b/src/PinAction/Program.cs @@ -102,7 +102,7 @@ args is ["help" /// /// 要处理的 YAML / YML 工作流文件路径。 /// 若文件处理成功返回 ;失败返回 。 - private static bool PinActionHash(string path) + internal static bool PinActionHash(string path) { // 读取文件内容,并按行分隔 string[] lines = File.ReadAllLines(path); @@ -230,7 +230,7 @@ private static bool PinActionHash(string path) /// 原始引用的版本或分支名称。 /// 对应的提交 SHA,通常为 40 位十六进制值。 /// 若能在目标仓库中找到与提交 SHA 对应的标签,则返回最合适的标签名;否则返回原始 。 - private static string ResolveRefinedTag(string repo, string tag, string hash) + internal static string ResolveRefinedTag(string repo, string tag, string hash) { if (string.IsNullOrWhiteSpace(tag) || string.IsNullOrWhiteSpace(hash)) { @@ -270,7 +270,7 @@ private static string ResolveRefinedTag(string repo, string tag, string hash) /// 左侧版本字符串。 /// 右侧版本字符串。 /// 若 小于 返回负数;相等返回 0;大于返回正数。 - private static int CompareVersionStrings(string left, string right) + internal static int CompareVersionStrings(string left, string right) { int[] leftParts = [.. NumberRegex().Matches(left).Select(m => int.Parse(m.Value))]; int[] rightParts = [.. NumberRegex().Matches(right).Select(m => int.Parse(m.Value))]; @@ -293,7 +293,7 @@ private static int CompareVersionStrings(string left, string right) /// /// GitHub API 客户端。 /// - private static readonly GitHubClient GitHubClient = new(new ProductHeaderValue("PinAction")) + internal static IGitHubClient GitHubClient { get; set; } = new GitHubClient(new ProductHeaderValue("PinAction")) { // 如果你想让请求使用 GitHub Token,可以将 Token 临时填在这里 // 记得用完后 撤销/删除/轮换 Token。 @@ -304,7 +304,7 @@ private static int CompareVersionStrings(string left, string right) /// 缓存已固定哈希值的 Action,避免同一 repo@tag 重复调用 GitHub API。 /// 键为 repo@tag,值为对应的提交 SHA。 /// - private static readonly ConcurrentDictionary PinedActions = new(); + internal static readonly ConcurrentDictionary PinedActions = new(); /// /// 匹配工作流中的 uses: 引用,提取仓库名和对应版本或分支名。 From b1d476e4e85cc4ad37360057fc36d198471a4d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B8=AD=E9=B8=AD=E3=80=8C=E3=82=AB=E3=83=A2=E3=80=8D?= <89643991+DuckDuckStudio@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:33:22 +0800 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=E5=B0=86=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E7=9A=84=20summary=20=E5=A1=AB=E5=88=B0=20Di?= =?UTF-8?q?splayName=20=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 并将原先的“测试 xxx 方法是否...”改为“xxx 方法应...” --- src/PinAction.tests/UnitTests.cs | 35 +++++++------------------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/src/PinAction.tests/UnitTests.cs b/src/PinAction.tests/UnitTests.cs index 0e20326..055f7be 100644 --- a/src/PinAction.tests/UnitTests.cs +++ b/src/PinAction.tests/UnitTests.cs @@ -70,10 +70,7 @@ public UnitTests() Program.GitHubClient = _githubClient.Object; } - /// - /// 测试 CompareVersionStrings 方法是否按自然顺序比较版本字符串。 - /// - [Fact] + [Fact(DisplayName = "CompareVersionStrings 方法应按自然顺序比较版本字符串")] public void CompareVersionStrings_OrdersNumericSegmentsNaturally() { Assert.True(Program.CompareVersionStrings("v1.9.0", "v1.10.0") < 0); @@ -81,10 +78,7 @@ public void CompareVersionStrings_OrdersNumericSegmentsNaturally() Assert.Equal(0, Program.CompareVersionStrings("release-1.2", "v1.2.0")); } - /// - /// 测试 ResolveRefinedTag 方法是否返回与提交匹配的最高版本标签。 - /// - [Fact] + [Fact(DisplayName = "ResolveRefinedTag 方法应返回与提交匹配的最高版本标签")] public void ResolveRefinedTag_ReturnsHighestVersionForMatchingCommit() { _repositories @@ -102,10 +96,7 @@ public void ResolveRefinedTag_ReturnsHighestVersionForMatchingCommit() _repositories.Verify(client => client.GetAllTags("owner", "repo"), Times.Once); } - /// - /// 测试 ResolveRefinedTag 方法是否在提交没有匹配标签时返回原始标签。 - /// - [Fact] + [Fact(DisplayName = "ResolveRefinedTag 方法应在提交没有匹配标签时返回原始标签")] public void ResolveRefinedTag_ReturnsOriginalTagWhenCommitHasNoMatchingTag() { _repositories @@ -120,10 +111,7 @@ public void ResolveRefinedTag_ReturnsOriginalTagWhenCommitHasNoMatchingTag() Assert.Equal("main", result); } - /// - /// 测试 PinActionHash 方法是否正确固定引用并保留内联注释。 - /// - [Fact] + [Fact(DisplayName = "PinActionHash 方法应正确固定引用并保留内联注释")] public void PinActionHash_PinsReferencesAndPreservesInlineComments() { string path = Path.Combine(_temporaryDirectory, "workflow.yml"); @@ -164,10 +152,7 @@ public void PinActionHash_PinsReferencesAndPreservesInlineComments() _repositories.Verify(client => client.GetAllTags("owner", "repo"), Times.Once); } - /// - /// 测试 PinActionHash 方法是否将附注标签对象解析为其目标提交哈希。 - /// - [Fact] + [Fact(DisplayName = "PinActionHash 方法应将附注标签对象解析为其目标提交哈希")] public void PinActionHash_ResolvesAnnotatedTagToCommitHash() { const string tagObjectSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -191,10 +176,7 @@ public void PinActionHash_ResolvesAnnotatedTagToCommitHash() _tags.Verify(client => client.Get("owner", "repo", tagObjectSha), Times.Once); } - /// - /// 测试 PinActionHash 方法是否跳过最终目标不是提交的附注标签。 - /// - [Fact] + [Fact(DisplayName = "PinActionHash 方法应跳过最终目标不是提交的附注标签")] public void PinActionHash_SkipsAnnotatedTagWhenResolvedTargetIsNotCommit() { const string tagObjectSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -221,10 +203,7 @@ public void PinActionHash_SkipsAnnotatedTagWhenResolvedTargetIsNotCommit() It.IsAny()), Times.Never); } - /// - /// 测试 PinActionHash 方法是否跳过已固定的和无效的仓库引用。 - /// - [Fact] + [Fact(DisplayName = "PinActionHash 方法应跳过已固定的和无效的仓库引用")] public void PinActionHash_SkipsAlreadyPinnedAndInvalidRepositoryReferences() { string path = Path.Combine(_temporaryDirectory, "workflow.yml");