mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-18 22:46:38 +08:00
365 lines
14 KiB
C#
365 lines
14 KiB
C#
using AtomUI.Controls;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace AtomUI.Controls.Shared.Tests.ImageLoading;
|
|
|
|
public class ImageCacheTests
|
|
{
|
|
[Fact]
|
|
public void Decoded_Cache_Eviction_Defers_Disposal_Until_Result_Lease_Is_Released()
|
|
{
|
|
using var cache = new ImageDecodedCache(maxBytes: 1_000, maxEntries: 1);
|
|
var firstImage = new TestImage();
|
|
var secondImage = new TestImage();
|
|
var firstEntry = CreateEntry(firstImage, decodedBytes: 400);
|
|
var secondEntry = CreateEntry(secondImage, decodedBytes: 400);
|
|
var firstKey = CreateDecodeKey("first");
|
|
var secondKey = CreateDecodeKey("second");
|
|
|
|
cache.TryAdd(firstKey, firstEntry).ShouldBeTrue();
|
|
using var firstResult = firstEntry.AcquireResult(
|
|
ImageLoadOrigin.DecodedMemory,
|
|
ImageSourceValidation.Current,
|
|
firstKey.ContentId.Value);
|
|
cache.TryAdd(secondKey, secondEntry).ShouldBeTrue();
|
|
|
|
firstImage.DisposeCount.ShouldBe(0);
|
|
firstResult.Dispose();
|
|
firstImage.DisposeCount.ShouldBe(1);
|
|
|
|
cache.Dispose();
|
|
secondImage.DisposeCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Decoded_Cache_Result_Acquisition_Holds_The_Image_Across_Clear()
|
|
{
|
|
using var cache = new ImageDecodedCache(maxBytes: 1_000, maxEntries: 1);
|
|
var image = new TestImage();
|
|
var entry = CreateEntry(image, decodedBytes: 400);
|
|
var key = CreateDecodeKey("atomic-result");
|
|
cache.TryAdd(key, entry).ShouldBeTrue();
|
|
|
|
cache.TryAcquireResult(
|
|
key,
|
|
ImageLoadOrigin.DecodedMemory,
|
|
ImageSourceValidation.Current,
|
|
stageDurations: null,
|
|
out var result)
|
|
.ShouldBeTrue();
|
|
cache.Clear(partitionHash: null);
|
|
|
|
image.DisposeCount.ShouldBe(0);
|
|
result.ShouldNotBeNull().Dispose();
|
|
image.DisposeCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Borrowed_Image_Is_Never_Disposed_By_Operation_Or_Result_Lease()
|
|
{
|
|
var image = new TestImage();
|
|
var entry = new ImageDecodedCacheEntry(
|
|
image,
|
|
ownsImage: false,
|
|
10,
|
|
10,
|
|
10,
|
|
10,
|
|
400,
|
|
null,
|
|
ImageLoadOrigin.Borrowed);
|
|
entry.RetainOperation();
|
|
|
|
using var result = entry.AcquireResult(
|
|
ImageLoadOrigin.Borrowed,
|
|
ImageSourceValidation.NotRequired,
|
|
contentId: null);
|
|
entry.ReleaseOperation();
|
|
result.Dispose();
|
|
|
|
image.DisposeCount.ShouldBe(0);
|
|
result.Image.ShouldBeSameAs(image);
|
|
}
|
|
|
|
[Fact]
|
|
public void Encoded_Cache_Uses_Content_Id_Lru_And_Partition_Clear()
|
|
{
|
|
using var cache = new ImageEncodedCache(maxBytes: 10, maxEntries: 2);
|
|
var firstContent = CreateValidatedContent([1, 2]);
|
|
var secondContent = CreateValidatedContent([3, 4]);
|
|
var thirdContent = CreateValidatedContent([5, 6]);
|
|
var first = new ImageEncodedContentKey("p1", firstContent.ContentId!.Value);
|
|
var second = new ImageEncodedContentKey("p2", secondContent.ContentId!.Value);
|
|
var third = new ImageEncodedContentKey("p1", thirdContent.ContentId!.Value);
|
|
cache.Set(first, firstContent);
|
|
cache.Set(second, secondContent);
|
|
cache.TryGet(first, out _).ShouldBeTrue();
|
|
|
|
cache.Set(third, thirdContent);
|
|
|
|
cache.TryGet(second, out _).ShouldBeFalse();
|
|
cache.TryGet(first, out _).ShouldBeTrue();
|
|
cache.TryGet(third, out _).ShouldBeTrue();
|
|
cache.Clear("p1");
|
|
cache.Count.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Snapshot_Index_Rejects_An_Older_Completion()
|
|
{
|
|
var index = new ImageSourceSnapshotIndex();
|
|
var sourceKey = new ImageSourceKey("source", "partition");
|
|
var olderGeneration = index.BeginResolution(sourceKey);
|
|
var newerGeneration = index.BeginResolution(sourceKey);
|
|
var older = CreateSnapshot(sourceKey, "old", olderGeneration);
|
|
var newer = CreateSnapshot(sourceKey, "new", newerGeneration);
|
|
|
|
index.TryCommit(newer).ShouldBeTrue();
|
|
index.TryCommit(older).ShouldBeFalse();
|
|
index.TryGet(sourceKey, out var current).ShouldBeTrue();
|
|
current.ShouldBe(newer);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task File_Cache_Uses_Stable_Content_And_Source_Layout()
|
|
{
|
|
var directory = CreateTempDirectory();
|
|
try
|
|
{
|
|
using var cache = new ImageFileCache(directory, maxBytes: 1_000, maxEntries: 10);
|
|
var content = CreateValidatedContent([1, 2, 3, 4]);
|
|
var contentKey = new ImageEncodedContentKey("partition", content.ContentId!.Value);
|
|
var sourceKey = new ImageSourceKey("source", "partition");
|
|
var snapshot = new ImageSourceSnapshot(
|
|
sourceKey,
|
|
"revision",
|
|
content.ContentId.Value,
|
|
1,
|
|
DateTimeOffset.UtcNow,
|
|
SecurityPolicyVersion: ImageSecurityPolicy.Version);
|
|
|
|
await cache.SetContentAsync(contentKey, content, CancellationToken.None);
|
|
await cache.SetSourceSnapshotAsync(snapshot, CancellationToken.None);
|
|
|
|
var loadedContent = await cache.TryGetContentAsync(contentKey, CancellationToken.None);
|
|
var loadedSnapshot = await cache.TryGetSourceSnapshotAsync(sourceKey, CancellationToken.None);
|
|
loadedContent.ShouldNotBeNull().Bytes.ShouldBe(content.Bytes);
|
|
loadedContent.Origin.ShouldBe(ImageLoadOrigin.Persistent);
|
|
loadedSnapshot.ShouldBe(snapshot);
|
|
File.ReadAllText(Path.Combine(directory, "manifest")).ShouldContain("formatRevision=");
|
|
Directory.Exists(Path.Combine(directory, "content", "partition")).ShouldBeTrue();
|
|
Directory.Exists(Path.Combine(directory, "content-metadata", "partition")).ShouldBeTrue();
|
|
Directory.Exists(Path.Combine(directory, "sources", "partition")).ShouldBeTrue();
|
|
Directory.Exists(Path.Combine(directory, "locks")).ShouldBeTrue();
|
|
Directory.Exists(Path.Combine(directory, "temp")).ShouldBeTrue();
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task File_Cache_Treats_Corrupt_Content_As_A_Miss_And_Removes_It()
|
|
{
|
|
var directory = CreateTempDirectory();
|
|
try
|
|
{
|
|
using var cache = new ImageFileCache(directory, maxBytes: 1_000, maxEntries: 10);
|
|
var content = CreateValidatedContent([1, 2, 3, 4]);
|
|
var key = new ImageEncodedContentKey("partition", content.ContentId!.Value);
|
|
await cache.SetContentAsync(key, content, CancellationToken.None);
|
|
var contentPath = Directory.EnumerateFiles(
|
|
Path.Combine(directory, "content"), "*.bin", SearchOption.AllDirectories).Single();
|
|
await File.WriteAllBytesAsync(contentPath, [9, 9, 9], TestContext.Current.CancellationToken);
|
|
|
|
(await cache.TryGetContentAsync(key, CancellationToken.None)).ShouldBeNull();
|
|
File.Exists(contentPath).ShouldBeFalse();
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task File_Cache_Does_Not_Overwrite_A_Newer_Source_Snapshot()
|
|
{
|
|
var directory = CreateTempDirectory();
|
|
try
|
|
{
|
|
using var cache = new ImageFileCache(directory, maxBytes: 1_000, maxEntries: 10);
|
|
var olderContent = CreateValidatedContent([1, 2, 3, 4]);
|
|
var newerContent = CreateValidatedContent([5, 6, 7, 8]);
|
|
var sourceKey = new ImageSourceKey("source", "partition");
|
|
var older = new ImageSourceSnapshot(
|
|
sourceKey,
|
|
"old",
|
|
olderContent.ContentId!.Value,
|
|
1,
|
|
DateTimeOffset.UtcNow,
|
|
SecurityPolicyVersion: ImageSecurityPolicy.Version);
|
|
var newer = new ImageSourceSnapshot(
|
|
sourceKey,
|
|
"new",
|
|
newerContent.ContentId!.Value,
|
|
2,
|
|
DateTimeOffset.UtcNow,
|
|
SecurityPolicyVersion: ImageSecurityPolicy.Version);
|
|
await cache.SetContentAsync(
|
|
new ImageEncodedContentKey("partition", olderContent.ContentId.Value),
|
|
olderContent,
|
|
CancellationToken.None);
|
|
await cache.SetContentAsync(
|
|
new ImageEncodedContentKey("partition", newerContent.ContentId.Value),
|
|
newerContent,
|
|
CancellationToken.None);
|
|
|
|
await cache.SetSourceSnapshotAsync(newer, CancellationToken.None);
|
|
await cache.SetSourceSnapshotAsync(older, CancellationToken.None);
|
|
|
|
(await cache.TryGetSourceSnapshotAsync(sourceKey, CancellationToken.None)).ShouldBe(newer);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task File_Cache_Rebuild_And_Clear_Only_Replace_Managed_Store_Content()
|
|
{
|
|
var directory = CreateTempDirectory();
|
|
try
|
|
{
|
|
var unmanagedPath = Path.Combine(directory, "application-owned.txt");
|
|
await File.WriteAllTextAsync(
|
|
unmanagedPath,
|
|
"keep",
|
|
TestContext.Current.CancellationToken);
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(directory, "manifest"),
|
|
"formatRevision=999\n",
|
|
TestContext.Current.CancellationToken);
|
|
var obsoleteContent = Path.Combine(directory, "content", "shared", "obsolete.bin");
|
|
Directory.CreateDirectory(Path.GetDirectoryName(obsoleteContent)!);
|
|
await File.WriteAllBytesAsync(
|
|
obsoleteContent,
|
|
[1, 2, 3],
|
|
TestContext.Current.CancellationToken);
|
|
|
|
using var cache = new ImageFileCache(directory, maxBytes: 1_000, maxEntries: 10);
|
|
|
|
File.Exists(obsoleteContent).ShouldBeFalse();
|
|
File.ReadAllText(unmanagedPath).ShouldBe("keep");
|
|
File.ReadAllText(Path.Combine(directory, "manifest")).ShouldBe("formatRevision=1\n");
|
|
|
|
var content = CreateValidatedContent([4, 5, 6]);
|
|
await cache.SetContentAsync(
|
|
new ImageEncodedContentKey(string.Empty, content.ContentId!.Value),
|
|
content,
|
|
CancellationToken.None);
|
|
await cache.ClearAsync(partitionHash: null, CancellationToken.None);
|
|
|
|
File.ReadAllText(unmanagedPath).ShouldBe("keep");
|
|
File.Exists(Path.Combine(directory, "manifest")).ShouldBeTrue();
|
|
Directory.Exists(Path.Combine(directory, "locks")).ShouldBeTrue();
|
|
Directory.EnumerateFiles(
|
|
Path.Combine(directory, "content"),
|
|
"*",
|
|
SearchOption.AllDirectories).ShouldBeEmpty();
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task File_Cache_Partition_Clear_Does_Not_Remove_Other_Partitions()
|
|
{
|
|
var directory = CreateTempDirectory();
|
|
try
|
|
{
|
|
using var cache = new ImageFileCache(directory, maxBytes: 1_000, maxEntries: 10);
|
|
var firstContent = CreateValidatedContent([1, 3, 5]);
|
|
var secondContent = CreateValidatedContent([2, 4, 6]);
|
|
var firstContentKey = new ImageEncodedContentKey("p1", firstContent.ContentId!.Value);
|
|
var secondContentKey = new ImageEncodedContentKey("p2", secondContent.ContentId!.Value);
|
|
var firstSourceKey = new ImageSourceKey("source-1", "p1");
|
|
var secondSourceKey = new ImageSourceKey("source-2", "p2");
|
|
await cache.SetContentAsync(firstContentKey, firstContent, CancellationToken.None);
|
|
await cache.SetContentAsync(secondContentKey, secondContent, CancellationToken.None);
|
|
await cache.SetSourceSnapshotAsync(
|
|
new ImageSourceSnapshot(
|
|
firstSourceKey,
|
|
"v1",
|
|
firstContent.ContentId.Value,
|
|
1,
|
|
DateTimeOffset.UtcNow,
|
|
SecurityPolicyVersion: ImageSecurityPolicy.Version),
|
|
CancellationToken.None);
|
|
await cache.SetSourceSnapshotAsync(
|
|
new ImageSourceSnapshot(
|
|
secondSourceKey,
|
|
"v2",
|
|
secondContent.ContentId.Value,
|
|
1,
|
|
DateTimeOffset.UtcNow,
|
|
SecurityPolicyVersion: ImageSecurityPolicy.Version),
|
|
CancellationToken.None);
|
|
|
|
await cache.ClearAsync("p1", CancellationToken.None);
|
|
|
|
(await cache.TryGetContentAsync(firstContentKey, CancellationToken.None)).ShouldBeNull();
|
|
(await cache.TryGetSourceSnapshotAsync(firstSourceKey, CancellationToken.None)).ShouldBeNull();
|
|
(await cache.TryGetContentAsync(secondContentKey, CancellationToken.None)).ShouldNotBeNull();
|
|
(await cache.TryGetSourceSnapshotAsync(secondSourceKey, CancellationToken.None)).ShouldNotBeNull();
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
private static ImageDecodedCacheEntry CreateEntry(TestImage image, long decodedBytes) =>
|
|
new(
|
|
image,
|
|
ownsImage: true,
|
|
10,
|
|
10,
|
|
10,
|
|
10,
|
|
decodedBytes,
|
|
"image/png");
|
|
|
|
private static ImageDecodeKey CreateDecodeKey(string value) =>
|
|
new(
|
|
string.Empty,
|
|
new ImageContentId(ImageCacheKey.Hash(value)),
|
|
new ImageDecodeSpec(10, 10, "codec"));
|
|
|
|
private static ImageEncodedContent CreateValidatedContent(byte[] bytes) =>
|
|
ImageLoadingTestSupport.CreateContent(bytes).MarkValidated();
|
|
|
|
private static ImageSourceSnapshot CreateSnapshot(
|
|
ImageSourceKey key,
|
|
string version,
|
|
long generation) =>
|
|
new(
|
|
key,
|
|
version,
|
|
new ImageContentId(ImageCacheKey.Hash(version)),
|
|
generation,
|
|
DateTimeOffset.UtcNow,
|
|
SecurityPolicyVersion: ImageSecurityPolicy.Version);
|
|
|
|
private static string CreateTempDirectory()
|
|
{
|
|
var directory = Path.Combine(Path.GetTempPath(), $"atomui-image-cache-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(directory);
|
|
return directory;
|
|
}
|
|
}
|