mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-18 14:37:19 +08:00
731 lines
27 KiB
C#
731 lines
27 KiB
C#
using System.Buffers.Binary;
|
|
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Text;
|
|
using AtomUI.Controls;
|
|
using Avalonia;
|
|
using Avalonia.Media;
|
|
using Avalonia.Threading;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace AtomUI.Desktop.Controls.Tests.ImagePreviewer;
|
|
|
|
public class ImagePreviewDisplayTrackerTests
|
|
{
|
|
public ImagePreviewDisplayTrackerTests()
|
|
{
|
|
AvaloniaTestApp.EnsureInitialized();
|
|
}
|
|
|
|
[Fact]
|
|
public void WaitForLoaded_Keeps_The_Previous_Image_While_The_Target_Is_Loading()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var first = NewLoadedEntry("first");
|
|
var secondStarted = NewSignal();
|
|
var releaseSecond = NewSignal();
|
|
var second = NewGatedEntry("second", secondStarted, releaseSecond);
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
using var tracker = NewTracker(() => mode);
|
|
|
|
tracker.SetCurrentItem(first);
|
|
WaitUntil(() => ReferenceEquals(tracker.EffectiveImage, first.FullImage), "first display");
|
|
|
|
tracker.SetCurrentItem(second);
|
|
StartGatedLoad(second);
|
|
WaitUntil(() => secondStarted.Task.IsCompleted, "second load start");
|
|
Dispatcher.UIThread.RunJobs();
|
|
tracker.EffectiveImage.ShouldBeSameAs(first.FullImage);
|
|
tracker.IsCurrentLoading.ShouldBeTrue();
|
|
tracker.IsCurrentFailed.ShouldBeFalse();
|
|
|
|
releaseSecond.TrySetResult();
|
|
WaitUntil(() => ReferenceEquals(tracker.EffectiveImage, second.FullImage), "display swap after load");
|
|
tracker.IsCurrentLoading.ShouldBeFalse();
|
|
|
|
second.Dispose();
|
|
first.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Immediate_Clears_The_Previous_Image_And_Reports_Loading_While_The_Target_Is_Pending()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var first = NewLoadedEntry("first");
|
|
var secondStarted = NewSignal();
|
|
var releaseSecond = NewSignal();
|
|
var second = NewGatedEntry("second", secondStarted, releaseSecond);
|
|
var mode = ImageSwitchMode.Immediate;
|
|
using var tracker = NewTracker(() => mode);
|
|
|
|
tracker.SetCurrentItem(first);
|
|
WaitUntil(() => ReferenceEquals(tracker.EffectiveImage, first.FullImage), "first display");
|
|
|
|
tracker.SetCurrentItem(second);
|
|
tracker.EffectiveImage.ShouldBeNull();
|
|
tracker.IsCurrentLoading.ShouldBeTrue(); // Idle 瞬态也必须立即驱动 loading presenter
|
|
|
|
StartGatedLoad(second);
|
|
WaitUntil(() => secondStarted.Task.IsCompleted, "second load start");
|
|
Dispatcher.UIThread.RunJobs();
|
|
tracker.EffectiveImage.ShouldBeNull();
|
|
tracker.IsCurrentLoading.ShouldBeTrue();
|
|
|
|
releaseSecond.TrySetResult();
|
|
WaitUntil(() => ReferenceEquals(tracker.EffectiveImage, second.FullImage), "display after load");
|
|
|
|
second.Dispose();
|
|
first.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void WaitForLoaded_Drops_The_Held_Image_When_The_Target_Fails()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var first = NewLoadedEntry("first");
|
|
var failing = new ImagePreviewEntry(new ImagePreviewItem(new BytesImageSource(
|
|
new byte[] { 1 }, $"tracker-fail-{Guid.NewGuid():N}")));
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
using var tracker = NewTracker(() => mode);
|
|
|
|
tracker.SetCurrentItem(first);
|
|
WaitUntil(() => ReferenceEquals(tracker.EffectiveImage, first.FullImage), "first display");
|
|
|
|
tracker.SetCurrentItem(failing);
|
|
failing.LoadFull(16, 16, ImageRequestPriority.Critical);
|
|
WaitUntil(() => tracker.IsCurrentFailed && tracker.EffectiveImage is null, "failed display");
|
|
|
|
failing.Dispose();
|
|
first.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Disposing_The_Retained_Source_Clears_The_Held_Image()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var first = NewLoadedEntry("first");
|
|
var secondStarted = NewSignal();
|
|
var releaseSecond = NewSignal();
|
|
var second = NewGatedEntry("second", secondStarted, releaseSecond);
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
using var tracker = NewTracker(() => mode);
|
|
|
|
tracker.SetCurrentItem(first);
|
|
WaitUntil(() => ReferenceEquals(tracker.EffectiveImage, first.FullImage), "first display");
|
|
tracker.SetCurrentItem(second);
|
|
StartGatedLoad(second);
|
|
WaitUntil(() => secondStarted.Task.IsCompleted && tracker.EffectiveImage is not null,
|
|
"held display");
|
|
tracker.EffectiveImage.ShouldBeSameAs(first.FullImage);
|
|
|
|
first.Dispose(); // 模拟集合移除保留帧源
|
|
|
|
WaitUntil(() => tracker.EffectiveImage is null, "held image cleared after dispose");
|
|
tracker.IsCurrentLoading.ShouldBeTrue();
|
|
|
|
releaseSecond.TrySetResult();
|
|
second.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Disposing_An_Entry_Clears_Display_Holders_Before_Releasing_The_Image_Lease()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var image = new TestImage();
|
|
ImagePreviewDisplayTracker? tracker = null;
|
|
IImage? displayAtLeaseRelease = image;
|
|
var lease = new CallbackDisposable(() => displayAtLeaseRelease = tracker?.EffectiveImage);
|
|
var result = new ImageLoadResult(
|
|
image,
|
|
lease,
|
|
24,
|
|
24,
|
|
24,
|
|
24,
|
|
"image/test",
|
|
ImageLoadOrigin.Local,
|
|
ImageSourceValidation.Current,
|
|
"test-content");
|
|
var entry = new ImagePreviewEntry(
|
|
new ImagePreviewItem(new BorrowedImageSource(image, "dispose-order")));
|
|
typeof(ImagePreviewEntry)
|
|
.GetField("_fullResult", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
|
|
.SetValue(entry, result);
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
tracker = NewTracker(() => mode);
|
|
tracker.SetCurrentItem(entry);
|
|
tracker.EffectiveImage.ShouldBeSameAs(image);
|
|
|
|
entry.Dispose();
|
|
|
|
displayAtLeaseRelease.ShouldBeNull();
|
|
tracker.EffectiveImage.ShouldBeNull();
|
|
tracker.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Unloading_An_Entry_Clears_Display_Holders_Before_Releasing_The_Image_Lease()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var image = new TestImage();
|
|
ImagePreviewDisplayTracker? tracker = null;
|
|
IImage? displayAtLeaseRelease = image;
|
|
var entry = NewEntryWithInjectedFullResult(
|
|
image,
|
|
new CallbackDisposable(() => displayAtLeaseRelease = tracker?.EffectiveImage),
|
|
"unload-order");
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
tracker = NewTracker(() => mode);
|
|
tracker.SetCurrentItem(entry);
|
|
tracker.EffectiveImage.ShouldBeSameAs(image);
|
|
|
|
entry.UnloadFull();
|
|
|
|
displayAtLeaseRelease.ShouldBeNull();
|
|
tracker.EffectiveImage.ShouldBeNull();
|
|
tracker.Dispose();
|
|
entry.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Failed_Commit_Never_Publishes_A_Failed_State_With_The_Previous_Image()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var image = new TestImage();
|
|
var entry = NewEntryWithInjectedFullResult(
|
|
image,
|
|
new CallbackDisposable(() => { }),
|
|
"failed-commit",
|
|
new BytesImageSource(new byte[] { 1 }, $"failed-commit-{Guid.NewGuid():N}"));
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
ImagePreviewDisplayTracker? tracker = null;
|
|
var observedInvalidState = false;
|
|
tracker = new ImagePreviewDisplayTracker(
|
|
() => mode,
|
|
() => observedInvalidState |= tracker!.IsCurrentFailed && tracker.EffectiveImage is not null);
|
|
tracker.SetCurrentItem(entry);
|
|
|
|
entry.LoadFull(24, 24, ImageRequestPriority.Critical, reload: true);
|
|
WaitUntil(() => tracker.IsCurrentFailed, "failed replacement commit");
|
|
|
|
observedInvalidState.ShouldBeFalse();
|
|
tracker.EffectiveImage.ShouldBeNull();
|
|
tracker.Dispose();
|
|
entry.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Clear_Drops_Everything_And_Unsubscribes_All_Entries()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var notified = false;
|
|
var secondStarted = NewSignal();
|
|
var releaseSecond = NewSignal();
|
|
var first = NewLoadedEntry("first");
|
|
var second = NewGatedEntry("second", secondStarted, releaseSecond);
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
using var tracker = new ImagePreviewDisplayTracker(() => mode, () => notified = true);
|
|
|
|
tracker.SetCurrentItem(first);
|
|
WaitUntil(() => tracker.EffectiveImage is not null, "first display");
|
|
tracker.SetCurrentItem(second);
|
|
StartGatedLoad(second);
|
|
WaitUntil(() => secondStarted.Task.IsCompleted, "second load start");
|
|
|
|
tracker.Clear();
|
|
|
|
tracker.EffectiveImage.ShouldBeNull();
|
|
tracker.IsCurrentLoading.ShouldBeFalse();
|
|
tracker.IsCurrentFailed.ShouldBeFalse();
|
|
notified.ShouldBeTrue(); // Clear 归零显示,回调按设计触发一次
|
|
notified = false;
|
|
|
|
releaseSecond.TrySetResult();
|
|
WaitUntil(() => second.FullState == ImageLoadState.Loaded, "second load completes unobserved");
|
|
Dispatcher.UIThread.RunJobs();
|
|
notified.ShouldBeFalse(); // 退订生效:后续事件不再触达 tracker
|
|
tracker.EffectiveImage.ShouldBeNull();
|
|
|
|
second.Dispose();
|
|
first.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Switching_Current_Unsubscribes_The_Previous_Current_Entry()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var notified = false;
|
|
var secondStarted = NewSignal();
|
|
var releaseSecond = NewSignal();
|
|
var first = NewLoadedEntry("first");
|
|
var second = NewGatedEntry("second", secondStarted, releaseSecond);
|
|
var third = NewLoadedEntry("third");
|
|
var mode = ImageSwitchMode.Immediate;
|
|
using var tracker = new ImagePreviewDisplayTracker(() => mode, () => notified = true);
|
|
|
|
tracker.SetCurrentItem(second);
|
|
StartGatedLoad(second);
|
|
WaitUntil(() => secondStarted.Task.IsCompleted, "second load start");
|
|
tracker.SetCurrentItem(third);
|
|
WaitUntil(() => tracker.EffectiveImage is not null, "third display");
|
|
notified = false;
|
|
|
|
releaseSecond.TrySetResult(); // 已被切走的 second 完成,不应触达 tracker
|
|
WaitUntil(() => second.FullState == ImageLoadState.Loaded, "second load completes unobserved");
|
|
Dispatcher.UIThread.RunJobs();
|
|
notified.ShouldBeFalse();
|
|
tracker.EffectiveImage.ShouldBeSameAs(third.FullImage);
|
|
|
|
second.Dispose();
|
|
third.Dispose();
|
|
first.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Immediate_Mode_Does_Not_Retain_The_Previous_Entry()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var first = NewLoadedEntry("first");
|
|
var pending = new ImagePreviewEntry(
|
|
new ImagePreviewItem(new BytesImageSource(
|
|
new byte[] { 1 }, $"tracker-immediate-pending-{Guid.NewGuid():N}")));
|
|
var mode = ImageSwitchMode.Immediate;
|
|
using var tracker = NewTracker(() => mode);
|
|
|
|
tracker.SetCurrentItem(first);
|
|
WaitUntil(() => tracker.EffectiveImage is not null, "first display");
|
|
tracker.SetCurrentItem(pending);
|
|
|
|
tracker.EffectiveImage.ShouldBeNull();
|
|
tracker.IsCurrentLoading.ShouldBeTrue();
|
|
GetRetainedItem(tracker).ShouldBeNull();
|
|
|
|
first.Dispose();
|
|
pending.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Retained_Item_Invariant_Holds_Across_Switches()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var first = NewLoadedEntry("first");
|
|
var secondStarted = NewSignal();
|
|
var releaseSecond = NewSignal();
|
|
var second = NewGatedEntry("second", secondStarted, releaseSecond);
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
using var tracker = NewTracker(() => mode);
|
|
|
|
tracker.SetCurrentItem(first);
|
|
WaitUntil(() => tracker.EffectiveImage is not null, "first display");
|
|
tracker.SetCurrentItem(second);
|
|
StartGatedLoad(second);
|
|
WaitUntil(() => secondStarted.Task.IsCompleted, "second load start");
|
|
|
|
var retained = GetRetainedItem(tracker).ShouldNotBeNull();
|
|
retained.ShouldBeSameAs(first);
|
|
retained.FullImage.ShouldNotBeNull(); // 不变量:retained 恒持有可用图
|
|
|
|
releaseSecond.TrySetResult();
|
|
WaitUntil(() => ReferenceEquals(tracker.EffectiveImage, second.FullImage), "display swap");
|
|
GetRetainedItem(tracker).ShouldBeSameAs(second);
|
|
|
|
second.Dispose();
|
|
first.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void WaitForLoaded_Holds_The_Retained_Image_During_The_Idle_Transient()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var first = NewLoadedEntry("first");
|
|
// 目标项尚处 Idle(LoadFull 未调用):切换瞬间的重算不得产生显示空洞
|
|
var pending = new ImagePreviewEntry(
|
|
new ImagePreviewItem(new BytesImageSource(
|
|
new byte[] { 1 }, $"tracker-idle-{Guid.NewGuid():N}")));
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
using var tracker = NewTracker(() => mode);
|
|
|
|
tracker.SetCurrentItem(first);
|
|
WaitUntil(() => ReferenceEquals(tracker.EffectiveImage, first.FullImage), "first display");
|
|
|
|
tracker.SetCurrentItem(pending); // Idle:无图、未加载、未失败
|
|
|
|
tracker.EffectiveImage.ShouldBeSameAs(first.FullImage);
|
|
|
|
pending.Dispose();
|
|
first.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void WaitForLoaded_Pulls_A_Seed_Targeted_By_The_Current_Tracker()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var seedStarted = NewSignal();
|
|
var releaseSeed = NewSignal();
|
|
var seed = NewGatedEntry("seed", seedStarted, releaseSeed);
|
|
var pending = new ImagePreviewEntry(
|
|
new ImagePreviewItem(new BytesImageSource(
|
|
new byte[] { 1 }, $"tracker-seed-{Guid.NewGuid():N}")));
|
|
ImagePreviewEntry? latestLoaded = null;
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
using var tracker = new ImagePreviewDisplayTracker(
|
|
() => mode,
|
|
() => { },
|
|
() => latestLoaded);
|
|
|
|
tracker.SetCurrentItem(seed);
|
|
StartGatedLoad(seed);
|
|
WaitUntil(() => seedStarted.Task.IsCompleted, "seed load start");
|
|
tracker.SetCurrentItem(pending);
|
|
|
|
releaseSeed.TrySetResult();
|
|
WaitUntil(() => seed.FullImage is not null, "superseded seed completion");
|
|
latestLoaded = seed;
|
|
tracker.Refresh();
|
|
|
|
tracker.EffectiveImage.ShouldBeSameAs(seed.FullImage);
|
|
|
|
seed.Dispose();
|
|
WaitUntil(() => tracker.EffectiveImage is null, "seed invalidated clears display");
|
|
|
|
pending.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void WaitForLoaded_Does_Not_Reuse_A_Target_Marker_From_Another_Tracker_Session()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var seed = NewLoadedEntry("other-session-seed");
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
using (var otherTracker = NewTracker(() => mode))
|
|
{
|
|
otherTracker.SetCurrentItem(seed);
|
|
}
|
|
var pending = new ImagePreviewEntry(
|
|
new ImagePreviewItem(new BytesImageSource(
|
|
new byte[] { 1 }, $"tracker-new-session-{Guid.NewGuid():N}")));
|
|
using var tracker = new ImagePreviewDisplayTracker(
|
|
() => mode,
|
|
() => { },
|
|
() => seed);
|
|
|
|
tracker.SetCurrentItem(pending);
|
|
|
|
tracker.EffectiveImage.ShouldBeNull();
|
|
tracker.IsCurrentLoading.ShouldBeTrue();
|
|
|
|
pending.Dispose();
|
|
seed.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void WaitForLoaded_Does_Not_Display_A_Preloaded_Entry_That_Was_Never_A_Target()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var preload = NewLoadedEntry("preload");
|
|
var pending = new ImagePreviewEntry(
|
|
new ImagePreviewItem(new BytesImageSource(
|
|
new byte[] { 1 }, $"tracker-current-{Guid.NewGuid():N}")));
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
using var tracker = new ImagePreviewDisplayTracker(
|
|
() => mode,
|
|
() => { },
|
|
() => preload);
|
|
|
|
tracker.SetCurrentItem(pending);
|
|
|
|
tracker.EffectiveImage.ShouldBeNull();
|
|
tracker.IsCurrentLoading.ShouldBeTrue();
|
|
|
|
pending.Dispose();
|
|
preload.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void WaitForLoaded_Advances_To_A_Newer_Completed_Previous_Target()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var first = NewLoadedEntry("first");
|
|
var secondStarted = NewSignal();
|
|
var releaseSecond = NewSignal();
|
|
var second = NewGatedEntry("second", secondStarted, releaseSecond);
|
|
var thirdStarted = NewSignal();
|
|
var releaseThird = NewSignal();
|
|
var third = NewGatedEntry("third", thirdStarted, releaseThird);
|
|
ImagePreviewEntry latestLoaded = first;
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
using var tracker = new ImagePreviewDisplayTracker(
|
|
() => mode,
|
|
() => { },
|
|
() => latestLoaded);
|
|
|
|
tracker.SetCurrentItem(first);
|
|
WaitUntil(() => ReferenceEquals(tracker.EffectiveImage, first.FullImage), "first display");
|
|
|
|
tracker.SetCurrentItem(second);
|
|
StartGatedLoad(second);
|
|
WaitUntil(() => secondStarted.Task.IsCompleted, "second load start");
|
|
tracker.SetCurrentItem(third);
|
|
|
|
releaseSecond.TrySetResult();
|
|
WaitUntil(() => second.FullImage is not null, "second load completion");
|
|
latestLoaded = second;
|
|
tracker.SetCurrentItem(third); // 当前目标未变时也允许重新计算最新完成候选
|
|
|
|
tracker.EffectiveImage.ShouldBeSameAs(second.FullImage);
|
|
tracker.IsCurrentLoading.ShouldBeTrue();
|
|
|
|
StartGatedLoad(third);
|
|
WaitUntil(() => thirdStarted.Task.IsCompleted, "third load start");
|
|
releaseThird.TrySetResult();
|
|
WaitUntil(() => third.FullImage is not null &&
|
|
ReferenceEquals(tracker.EffectiveImage, third.FullImage),
|
|
"third display");
|
|
|
|
third.Dispose();
|
|
second.Dispose();
|
|
first.Dispose();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void WaitForLoaded_Does_Not_Regress_When_An_Older_Target_Completes_Late()
|
|
{
|
|
Dispatcher.UIThread.Invoke(() =>
|
|
{
|
|
var first = NewLoadedEntry("first");
|
|
var olderStarted = NewSignal();
|
|
var releaseOlder = NewSignal();
|
|
var older = NewGatedEntry("older", olderStarted, releaseOlder);
|
|
var newer = NewLoadedEntry("newer");
|
|
var pending = new ImagePreviewEntry(
|
|
new ImagePreviewItem(new BytesImageSource(
|
|
new byte[] { 1 }, $"tracker-pending-{Guid.NewGuid():N}")));
|
|
ImagePreviewEntry latestLoaded = first;
|
|
var mode = ImageSwitchMode.WaitForLoaded;
|
|
using var tracker = new ImagePreviewDisplayTracker(
|
|
() => mode,
|
|
() => { },
|
|
() => latestLoaded);
|
|
|
|
tracker.SetCurrentItem(first);
|
|
tracker.SetCurrentItem(older);
|
|
StartGatedLoad(older);
|
|
WaitUntil(() => olderStarted.Task.IsCompleted, "older load start");
|
|
tracker.SetCurrentItem(newer);
|
|
WaitUntil(() => ReferenceEquals(tracker.EffectiveImage, newer.FullImage), "newer display");
|
|
tracker.SetCurrentItem(pending);
|
|
|
|
releaseOlder.TrySetResult();
|
|
WaitUntil(() => older.FullImage is not null, "older late completion");
|
|
latestLoaded = older;
|
|
tracker.SetCurrentItem(pending);
|
|
|
|
tracker.EffectiveImage.ShouldBeSameAs(newer.FullImage);
|
|
|
|
pending.Dispose();
|
|
newer.Dispose();
|
|
older.Dispose();
|
|
first.Dispose();
|
|
});
|
|
}
|
|
|
|
private static ImagePreviewDisplayTracker NewTracker(Func<ImageSwitchMode> mode)
|
|
{
|
|
return new ImagePreviewDisplayTracker(mode, () => { });
|
|
}
|
|
|
|
private static ImagePreviewEntry NewLoadedEntry(string key)
|
|
{
|
|
var entry = new ImagePreviewEntry(
|
|
new ImagePreviewItem(new BorrowedImageSource(new TestImage(), key)));
|
|
// tracker 不发起加载,测试显式驱动(与既有 entry 级测试一致)
|
|
entry.LoadFull(16, 16, ImageRequestPriority.Critical);
|
|
WaitUntil(() => entry.FullState == ImageLoadState.Loaded, $"{key} load");
|
|
return entry;
|
|
}
|
|
|
|
private static ImagePreviewEntry NewGatedEntry(
|
|
string key,
|
|
TaskCompletionSource started,
|
|
TaskCompletionSource release)
|
|
{
|
|
return new ImagePreviewEntry(new ImagePreviewItem(new StreamImageSource(
|
|
async token =>
|
|
{
|
|
started.TrySetResult();
|
|
await release.Task.WaitAsync(token);
|
|
return new MemoryStream(CreatePng(16, 16));
|
|
},
|
|
$"tracker-{key}-{Guid.NewGuid():N}",
|
|
"v1")));
|
|
}
|
|
|
|
private static void StartGatedLoad(ImagePreviewEntry entry)
|
|
{
|
|
entry.LoadFull(16, 16, ImageRequestPriority.Critical);
|
|
}
|
|
|
|
private static ImagePreviewEntry? GetRetainedItem(ImagePreviewDisplayTracker tracker)
|
|
{
|
|
return (ImagePreviewEntry?)typeof(ImagePreviewDisplayTracker)
|
|
.GetField(
|
|
"_retainedItem",
|
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
|
|
.GetValue(tracker);
|
|
}
|
|
|
|
private static ImagePreviewEntry NewEntryWithInjectedFullResult(
|
|
IImage image,
|
|
IDisposable lease,
|
|
string key,
|
|
ImageSource? source = null)
|
|
{
|
|
var result = new ImageLoadResult(
|
|
image,
|
|
lease,
|
|
24,
|
|
24,
|
|
24,
|
|
24,
|
|
"image/test",
|
|
ImageLoadOrigin.Local,
|
|
ImageSourceValidation.Current,
|
|
"test-content");
|
|
var entry = new ImagePreviewEntry(
|
|
new ImagePreviewItem(source ?? new BorrowedImageSource(image, key)));
|
|
typeof(ImagePreviewEntry)
|
|
.GetField("_fullResult", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
|
|
.SetValue(entry, result);
|
|
return entry;
|
|
}
|
|
|
|
private static TaskCompletionSource NewSignal() =>
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
private static void WaitUntil(Func<bool> predicate, string description)
|
|
{
|
|
var timeout = System.Diagnostics.Stopwatch.StartNew();
|
|
while (!predicate())
|
|
{
|
|
Dispatcher.UIThread.RunJobs();
|
|
if (timeout.Elapsed >= TimeSpan.FromSeconds(5))
|
|
{
|
|
throw new TimeoutException($"Timed out waiting for {description}.");
|
|
}
|
|
Thread.Yield();
|
|
}
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
|
|
private static byte[] CreatePng(int width, int height)
|
|
{
|
|
using var output = new MemoryStream();
|
|
output.Write([0x89, (byte)'P', (byte)'N', (byte)'G', 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
|
Span<byte> header = stackalloc byte[13];
|
|
BinaryPrimitives.WriteUInt32BigEndian(header[..4], checked((uint)width));
|
|
BinaryPrimitives.WriteUInt32BigEndian(header[4..8], checked((uint)height));
|
|
header[8] = 8;
|
|
header[9] = 6;
|
|
WriteChunk(output, "IHDR", header);
|
|
|
|
using var raw = new MemoryStream();
|
|
var row = new byte[checked(width * 4 + 1)];
|
|
for (var y = 0; y < height; y++)
|
|
{
|
|
raw.Write(row);
|
|
}
|
|
raw.Position = 0;
|
|
using var compressed = new MemoryStream();
|
|
using (var zlib = new ZLibStream(compressed, CompressionLevel.SmallestSize, leaveOpen: true))
|
|
{
|
|
raw.CopyTo(zlib);
|
|
}
|
|
WriteChunk(output, "IDAT", compressed.ToArray());
|
|
WriteChunk(output, "IEND", []);
|
|
return output.ToArray();
|
|
}
|
|
|
|
private static void WriteChunk(Stream output, string type, ReadOnlySpan<byte> data)
|
|
{
|
|
Span<byte> length = stackalloc byte[4];
|
|
BinaryPrimitives.WriteUInt32BigEndian(length, checked((uint)data.Length));
|
|
output.Write(length);
|
|
var typeBytes = Encoding.ASCII.GetBytes(type);
|
|
output.Write(typeBytes);
|
|
output.Write(data);
|
|
var crcBytes = new byte[typeBytes.Length + data.Length];
|
|
typeBytes.CopyTo(crcBytes, 0);
|
|
data.CopyTo(crcBytes.AsSpan(typeBytes.Length));
|
|
Span<byte> crc = stackalloc byte[4];
|
|
BinaryPrimitives.WriteUInt32BigEndian(crc, CalculateCrc32(crcBytes));
|
|
output.Write(crc);
|
|
}
|
|
|
|
private static uint CalculateCrc32(byte[] bytes)
|
|
{
|
|
var crc = uint.MaxValue;
|
|
foreach (var value in bytes)
|
|
{
|
|
crc ^= value;
|
|
for (var bit = 0; bit < 8; bit++)
|
|
{
|
|
crc = (crc & 1) != 0 ? 0xedb88320u ^ (crc >> 1) : crc >> 1;
|
|
}
|
|
}
|
|
return ~crc;
|
|
}
|
|
|
|
private sealed class TestImage : IImage
|
|
{
|
|
public Size Size => new(24, 24);
|
|
|
|
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class CallbackDisposable(Action callback) : IDisposable
|
|
{
|
|
private Action? _callback = callback;
|
|
|
|
public void Dispose()
|
|
{
|
|
Interlocked.Exchange(ref _callback, null)?.Invoke();
|
|
}
|
|
}
|
|
}
|