using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dpz.Core.SourceGenerator;
using Dpz.Core.SourceGenerator.Attributes;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using ZiggyCreatures.Caching.Fusion;

namespace Dpz.Core.ServiceTest;

[TestFixture]
public class CacheSourceGeneratorTest
{
    [Test]
    public void GeneratedArticleLikeServiceCacheDoesNotUseRuntimeKeyBuilderOrInvalidationExtensions()
    {
        var generatedSources = RunGenerator(ArticleLikeServiceSource);
        var decoratorSource = generatedSources["GeneratedCachedArticleLikeService.g.cs"];
        var metadataSource = generatedSources["GeneratedArticleLikeServiceCacheMetadata.g.cs"];
        var cacheSource = decoratorSource + Environment.NewLine + metadataSource;

        Assert.That(cacheSource, Does.Not.Contain("FusionCacheKeyBuilder"));
        Assert.That(cacheSource, Does.Not.Contain("BuildFromObject"));
        Assert.That(cacheSource, Does.Not.Contain("GetProperties"));
        Assert.That(cacheSource, Does.Not.Contain("FusionCacheInvalidationExtensions"));

        Assert.That(metadataSource, Does.Contain("public const string GetTopArticlesAsyncPrefix"));
        Assert.That(
            metadataSource,
            Does.Contain("public const string GetTopArticlesAsyncMethodTag")
        );
        Assert.That(metadataSource, Does.Contain("public static string[] GetPrefixes()"));
        Assert.That(
            metadataSource,
            Does.Contain("public static string GetMethodTag(string methodName)")
        );
        Assert.That(metadataSource, Does.Contain("BuildGetTopArticlesAsyncCacheKey"));
        Assert.That(metadataSource, Does.Contain("EscapeCacheKeySegment"));
        Assert.That(metadataSource, Does.Contain("FormatCacheKeyCollection"));
        Assert.That(
            metadataSource,
            Does.Contain("segments.Sort(global::System.StringComparer.Ordinal);")
        );

        Assert.That(decoratorSource, Does.Contain("PagedListWarp<"));
        Assert.That(decoratorSource, Does.Contain("await inner.ApplyHotFieldsAsync(result);"));
        Assert.That(decoratorSource, Does.Contain("\"home\""));
        Assert.That(decoratorSource, Does.Contain("GetTopArticlesAsyncMethodTag"));
        Assert.That(decoratorSource, Does.Contain("RemoveByTagAsync"));
        Assert.That(decoratorSource, Does.Contain("GetPagesAsyncMethodTag"));
        Assert.That(
            decoratorSource,
            Does.Contain(
                "await fusionCache.RemoveAsync(GeneratedArticleLikeServiceCacheMetadata.BuildGetExplicitAsyncCacheKey(), token: cancellationToken);"
            )
        );
        Assert.That(
            decoratorSource,
            Does.Not.Contain(
                "RemoveAsync(GeneratedArticleLikeServiceCacheMetadata.BuildGetTopArticlesAsyncCacheKey"
            )
        );
        Assert.That(
            decoratorSource,
            Does.Not.Contain(
                "RemoveAsync(GeneratedArticleLikeServiceCacheMetadata.BuildGetPagesAsyncCacheKey"
            )
        );
        Assert.That(
            decoratorSource,
            Does.Contain(
                "options.SetDuration(global::System.TimeSpan.FromSeconds(60)).SetFailSafe(false)"
            )
        );
        Assert.That(
            decoratorSource,
            Does.Not.Contain(
                "options.SetDuration(global::System.TimeSpan.FromSeconds(3600)).SetFailSafe(false)"
            )
        );
    }

    [Test]
    public void NoParameterCachedMethodInvalidationAlsoRemovesDeterministicCacheKey()
    {
        var generatedSources = RunGenerator(RobotsLikeServiceSource);
        var decoratorSource = generatedSources["GeneratedCachedRobotsLikeService.g.cs"];
        var metadataSource = generatedSources["GeneratedRobotsLikeServiceCacheMetadata.g.cs"];

        Assert.That(
            metadataSource,
            Does.Contain("public static string BuildGetRobotsAsyncCacheKey()")
        );
        Assert.That(decoratorSource, Does.Contain("RemoveByTagAsync"));
        Assert.That(decoratorSource, Does.Contain("GetRobotsAsyncMethodTag"));
        Assert.That(
            decoratorSource,
            Does.Contain(
                "await fusionCache.RemoveAsync(GeneratedRobotsLikeServiceCacheMetadata.BuildGetRobotsAsyncCacheKey(), token: cancellationToken);"
            )
        );
    }

    [Test]
    public void ExplicitCacheKeyDoesNotRequireFormattingComplexParameters()
    {
        var generatedSources = RunGenerator(ArticleLikeServiceSource);
        var metadataSource = generatedSources["GeneratedArticleLikeServiceCacheMetadata.g.cs"];

        Assert.That(
            metadataSource,
            Does.Contain("public static string BuildGetExplicitAsyncCacheKey()")
        );
        Assert.That(metadataSource, Does.Contain("EscapeCacheKeySegment(\"fixed:query&v=1\")"));
        Assert.That(
            metadataSource,
            Does.Not.Contain(
                "BuildGetExplicitAsyncCacheKey(global::Dpz.Core.Public.ViewModel.ArticleQuery"
            )
        );
    }

    [Test]
    public void ComplexDtoCacheKeyExpandsPublicReadableProperties()
    {
        var generatedSources = RunGenerator(ArticleLikeServiceSource);
        var metadataSource = generatedSources["GeneratedArticleLikeServiceCacheMetadata.g.cs"];

        Assert.That(
            metadataSource,
            Does.Contain(
                "BuildGetQueryAsyncCacheKey(global::Dpz.Core.Public.ViewModel.ArticleQuery query)"
            )
        );
        Assert.That(metadataSource, Does.Contain("\"query.Account=\""));
        Assert.That(metadataSource, Does.Contain("\"query.PageIndex=\""));
        Assert.That(metadataSource, Does.Contain("\"query.PageSize=\""));
        Assert.That(metadataSource, Does.Contain("\"query.PublishedAfter=\""));
        Assert.That(metadataSource, Does.Contain("\"query.Range.Start=\""));
        Assert.That(metadataSource, Does.Contain("\"query.Tags=\""));
        Assert.That(metadataSource, Does.Contain("FormatCacheKeyValue(query.Tags)"));
        Assert.That(metadataSource, Does.Not.Contain("FormatCacheKeyValue(query)"));
        Assert.That(metadataSource, Does.Not.Contain("query.ToString()"));
    }

    [Test]
    public void UnsupportedDtoPropertyReportsCacheDiagnosticWithPropertyPath()
    {
        var diagnostics = RunGeneratorDiagnostics(UnsupportedQueryServiceSource);

        Assert.That(
            diagnostics,
            Has.One.Matches<Diagnostic>(diagnostic =>
                diagnostic.Id == "DPZ_CACHE001"
                && diagnostic.GetMessage().Contains("query.Payload", StringComparison.Ordinal)
                && diagnostic.GetMessage().Contains("System.IO.Stream", StringComparison.Ordinal)
            )
        );
    }

    [Test]
    public void CacheAttributeStoresAdditionalTags()
    {
        var attribute = new CacheAttribute { AdditionalTags = ["article", "home"] };

        Assert.That(attribute.AdditionalTags, Is.EqualTo(new[] { "article", "home" }));
    }

    private static Dictionary<string, string> RunGenerator(string source)
    {
        var syntaxTree = CSharpSyntaxTree.ParseText(
            source,
            new CSharpParseOptions(LanguageVersion.Latest)
        );
        var compilation = CSharpCompilation.Create(
            "Dpz.Core.CacheGenerator.TestAssembly",
            [syntaxTree],
            GetMetadataReferences(),
            new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
        );

        var generator = new ServiceRegistrationGenerator();
        var driver = CSharpGeneratorDriver.Create(generator).RunGenerators(compilation);
        var result = driver.GetRunResult();

        Assert.That(result.Diagnostics, Is.Empty);

        return result
            .Results.Single()
            .GeneratedSources.ToDictionary(
                sourceResult => sourceResult.HintName,
                sourceResult => sourceResult.SourceText.ToString()
            );
    }

    private static IReadOnlyList<Diagnostic> RunGeneratorDiagnostics(string source)
    {
        var syntaxTree = CSharpSyntaxTree.ParseText(
            source,
            new CSharpParseOptions(LanguageVersion.Latest)
        );
        var compilation = CSharpCompilation.Create(
            "Dpz.Core.CacheGenerator.TestAssembly",
            [syntaxTree],
            GetMetadataReferences(),
            new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
        );

        var generator = new ServiceRegistrationGenerator();
        var driver = CSharpGeneratorDriver.Create(generator).RunGenerators(compilation);
        var result = driver.GetRunResult();
        return result.Diagnostics;
    }

    private static IEnumerable<MetadataReference> GetMetadataReferences()
    {
        var trustedPlatformAssemblies =
            (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string) ?? string.Empty;
        var runtimeReferences = trustedPlatformAssemblies
            .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
            .Select(static path => MetadataReference.CreateFromFile(path));

        var projectReferences = new[]
        {
            typeof(CacheAttribute).Assembly.Location,
            typeof(IFusionCache).Assembly.Location,
            typeof(Web.Pager.IPagedList<>).Assembly.Location,
        }
            .Where(static path => !string.IsNullOrWhiteSpace(path))
            .Select(static path => MetadataReference.CreateFromFile(path));

        return runtimeReferences.Concat(projectReferences);
    }

    private const string ArticleLikeServiceSource = """
        using System;
        using System.Collections.Generic;
        using System.Threading;
        using System.Threading.Tasks;
        using Dpz.Core.SourceGenerator.Attributes;
        using Dpz.Core.Web.Pager;
        using ZiggyCreatures.Caching.Fusion;

        namespace Dpz.Core.Public.ViewModel
        {
            public sealed class ArticleLikeResponse
            {
                public string Title { get; set; } = string.Empty;
            }

            public abstract class ArticlePageQuery
            {
                public int PageIndex { get; set; } = 1;

                public int PageSize { get; set; } = 20;
            }

            public sealed class ArticleQueryRange
            {
                public DateTime? Start { get; set; }

                public DateTime? End { get; set; }
            }

            public sealed class ArticleQuery : ArticlePageQuery
            {
                public string Keyword { get; set; } = string.Empty;

                public string? Account { get; set; }

                public DateTime? PublishedAfter { get; set; }

                public string[] Tags { get; set; } = Array.Empty<string>();

                public ArticleQueryRange? Range { get; set; }
            }
        }

        namespace Dpz.Core.Service.RepositoryService
        {
            public interface IArticleLikeService
            {
                Task<IReadOnlyList<Dpz.Core.Public.ViewModel.ArticleLikeResponse>> GetTopArticlesAsync(
                    string name,
                    int[] ids,
                    CancellationToken cancellationToken = default
                );

                Task<IPagedList<Dpz.Core.Public.ViewModel.ArticleLikeResponse>> GetPagesAsync(
                    int page,
                    IEnumerable<Guid> ids,
                    CancellationToken cancellationToken = default
                );

                Task<Dpz.Core.Public.ViewModel.ArticleLikeResponse> GetExplicitAsync(
                    Dpz.Core.Public.ViewModel.ArticleQuery query,
                    CancellationToken cancellationToken = default
                );

                Task<Dpz.Core.Public.ViewModel.ArticleLikeResponse> GetQueryAsync(
                    Dpz.Core.Public.ViewModel.ArticleQuery query,
                    CancellationToken cancellationToken = default
                );

                Task SaveAsync(CancellationToken cancellationToken = default);
            }
        }

        namespace Dpz.Core.Service.RepositoryServiceImpl
        {
            public sealed class ArticleLikeService(IFusionCache fusionCache)
            {
                [Cache(
                    Prefix = "article",
                    ExpirationSeconds = 60,
                    PostProcess = nameof(ApplyHotFieldsAsync),
                    AdditionalTags = new[] { "home", "article" }
                )]
                public Task<IReadOnlyList<Dpz.Core.Public.ViewModel.ArticleLikeResponse>> GetTopArticlesAsync(
                    string name,
                    int[] ids,
                    CancellationToken cancellationToken = default
                )
                {
                    IReadOnlyList<Dpz.Core.Public.ViewModel.ArticleLikeResponse> result =
                        new List<Dpz.Core.Public.ViewModel.ArticleLikeResponse>();
                    return Task.FromResult(result);
                }

                [Cache]
                public Task<IPagedList<Dpz.Core.Public.ViewModel.ArticleLikeResponse>> GetPagesAsync(
                    int page,
                    IEnumerable<Guid> ids,
                    CancellationToken cancellationToken = default
                )
                {
                    return Task.FromResult<IPagedList<Dpz.Core.Public.ViewModel.ArticleLikeResponse>>(
                        default!
                    );
                }

                [Cache(CacheKey = "fixed:query&v=1")]
                public Task<Dpz.Core.Public.ViewModel.ArticleLikeResponse> GetExplicitAsync(
                    Dpz.Core.Public.ViewModel.ArticleQuery query,
                    CancellationToken cancellationToken = default
                )
                {
                    return Task.FromResult(new Dpz.Core.Public.ViewModel.ArticleLikeResponse());
                }

                [Cache]
                public Task<Dpz.Core.Public.ViewModel.ArticleLikeResponse> GetQueryAsync(
                    Dpz.Core.Public.ViewModel.ArticleQuery query,
                    CancellationToken cancellationToken = default
                )
                {
                    return Task.FromResult(new Dpz.Core.Public.ViewModel.ArticleLikeResponse());
                }

                internal Task ApplyHotFieldsAsync(
                    IReadOnlyList<Dpz.Core.Public.ViewModel.ArticleLikeResponse> articles
                )
                {
                    return Task.CompletedTask;
                }

                [InvalidateCache(Methods = new[] { nameof(GetTopArticlesAsync), nameof(GetPagesAsync), nameof(GetExplicitAsync) })]
                public Task SaveAsync(CancellationToken cancellationToken = default)
                {
                    return Task.CompletedTask;
                }
            }
        }
        """;

    private const string UnsupportedQueryServiceSource = """
        using System.IO;
        using System.Threading;
        using System.Threading.Tasks;
        using Dpz.Core.SourceGenerator.Attributes;

        namespace Dpz.Core.Public.ViewModel
        {
            public sealed class UnsupportedQuery
            {
                public Stream? Payload { get; set; }
            }

            public sealed class UnsupportedResponse
            {
                public string Title { get; set; } = string.Empty;
            }
        }

        namespace Dpz.Core.Service.RepositoryService
        {
            public interface IUnsupportedQueryService
            {
                Task<Dpz.Core.Public.ViewModel.UnsupportedResponse> SearchAsync(
                    Dpz.Core.Public.ViewModel.UnsupportedQuery query,
                    CancellationToken cancellationToken = default
                );
            }
        }

        namespace Dpz.Core.Service.RepositoryServiceImpl
        {
            public sealed class UnsupportedQueryService
            {
                [Cache]
                public Task<Dpz.Core.Public.ViewModel.UnsupportedResponse> SearchAsync(
                    Dpz.Core.Public.ViewModel.UnsupportedQuery query,
                    CancellationToken cancellationToken = default
                )
                {
                    return Task.FromResult(new Dpz.Core.Public.ViewModel.UnsupportedResponse());
                }
            }
        }
        """;

    private const string RobotsLikeServiceSource = """
        using System.Threading;
        using System.Threading.Tasks;
        using Dpz.Core.SourceGenerator.Attributes;
        using ZiggyCreatures.Caching.Fusion;

        namespace Dpz.Core.Service.RepositoryService
        {
            public interface IRobotsLikeService
            {
                Task<string> GetRobotsAsync(CancellationToken cancellationToken = default);

                Task SaveRobotsAsync(string content, CancellationToken cancellationToken = default);
            }
        }

        namespace Dpz.Core.Service.RepositoryServiceImpl
        {
            public sealed class RobotsLikeService(IFusionCache fusionCache)
            {
                [Cache(ExpirationSeconds = 2592000)]
                public Task<string> GetRobotsAsync(CancellationToken cancellationToken = default)
                {
                    return Task.FromResult(string.Empty);
                }

                [InvalidateCache(Methods = new[] { nameof(GetRobotsAsync) })]
                public Task SaveRobotsAsync(
                    string content,
                    CancellationToken cancellationToken = default
                )
                {
                    return Task.CompletedTask;
                }
            }
        }
        """;
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

这是一个用于测试缓存源代码生成器(Cache Source Generator)的单元测试类。下面是详细解释:

主要功能

这个测试类验证了一个源代码生成器(ServiceRegistrationGenerator)的行为,该生成器会为标记了 [Cache][InvalidateCache] 特性的服务方法自动生成缓存装饰器代码。

核心测试方法

1. GeneratedArticleLikeServiceCacheDoesNotUseRuntimeKeyBuilderOrInvalidationExtensions

验证生成的缓存代码:

  • 不包含运行时反射机制:不使用 FusionCacheKeyBuilderBuildFromObjectGetProperties 等运行时构建
  • 生成了静态元数据:包含缓存键前缀、方法标签、键构建方法
  • 正确处理缓存失效
    • 使用 RemoveByTagAsync 移除带标签的缓存
    • 显式移除确定性键(如 GetExplicitAsync
    • 不会为带标签的方法生成直接移除调用
  • 保留了业务逻辑:如 ApplyHotFieldsAsync 后处理方法
  • 正确应用缓存配置:60秒过期时间、禁用故障保护

2. NoParameterCachedMethodInvalidationAlsoRemovesDeterministicCacheKey

验证无参数方法:

  • 生成确定性缓存键构建方法(无参数)
  • 失效时既移除标签关联的缓存,也移除确定性键

3. ExplicitCacheKeyDoesNotRequireFormattingComplexParameters

验证显式缓存键:

  • 使用 CacheKey = "fixed:query&v=1" 时不需要解析复杂参数
  • 生成无参数的键构建方法

4. ComplexDtoCacheKeyExpandsPublicReadableProperties

验证复杂对象的缓存键构建:

  • 递归展开 DTO 属性ArticleQuery 的所有公共可读属性(Account、PageIndex、Tags、Range.Start 等)
  • 处理集合类型:使用 FormatCacheKeyValue 格式化数组
  • 避免使用 ToString():直接使用属性值

5. UnsupportedDtoPropertyReportsCacheDiagnosticWithPropertyPath

验证错误诊断:

  • 当 DTO 包含不支持的属性类型(如 System.IO.Stream)时
  • 生成诊断信息(DPZ_CACHE001),包含属性路径和类型信息

6. CacheAttributeStoresAdditionalTags

验证 CacheAttributeAdditionalTags 属性能正确存储额外的缓存标签。

辅助方法

  • RunGenerator: 解析源代码、创建编译、运行生成器,返回生成的代码字典
  • RunGeneratorDiagnostics: 运行生成器并返回诊断信息(用于错误检测)
  • GetMetadataReferences: 收集运行时程序集引用和项目引用

测试数据

定义了三个测试用源代码字符串:

  1. ArticleLikeServiceSource: 包含多种缓存场景的文章服务
  2. UnsupportedQueryServiceSource: 包含不支持类型的查询服务
  3. RobotsLikeServiceSource: 无参数缓存方法示例

关键验证点

  • 生成的代码是编译时生成,避免运行时反射损耗
  • 缓存键是确定性且可预测
  • 支持标签化失效精确失效两种策略
  • 对复杂对象参数进行深度属性展开
  • 提供友好的诊断信息用于开发时错误排查
评论加载中...