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
验证生成的缓存代码:
- 不包含运行时反射机制:不使用
FusionCacheKeyBuilder、BuildFromObject、GetProperties等运行时构建 - 生成了静态元数据:包含缓存键前缀、方法标签、键构建方法
- 正确处理缓存失效:
- 使用
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
验证 CacheAttribute 的 AdditionalTags 属性能正确存储额外的缓存标签。
辅助方法
- RunGenerator: 解析源代码、创建编译、运行生成器,返回生成的代码字典
- RunGeneratorDiagnostics: 运行生成器并返回诊断信息(用于错误检测)
- GetMetadataReferences: 收集运行时程序集引用和项目引用
测试数据
定义了三个测试用源代码字符串:
- ArticleLikeServiceSource: 包含多种缓存场景的文章服务
- UnsupportedQueryServiceSource: 包含不支持类型的查询服务
- RobotsLikeServiceSource: 无参数缓存方法示例
关键验证点
- 生成的代码是编译时生成,避免运行时反射损耗
- 缓存键是确定性且可预测的
- 支持标签化失效和精确失效两种策略
- 对复杂对象参数进行深度属性展开
- 提供友好的诊断信息用于开发时错误排查
AI 正在分析代码…
评论加载中...