Files

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;
}
}