using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Buffers.Binary; using System.IO.Compression; using System.Text; using AtomUI.Controls; using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Threading; using Shouldly; using Xunit; namespace AtomUI.Desktop.Controls.Tests.ImagePreviewer; public class ImagePreviewerImageLoadingTests { public ImagePreviewerImageLoadingTests() { AvaloniaTestApp.EnsureInitialized(); } [Fact] public void Clearing_And_Readding_The_Same_File_Path_Loads_Replaced_Content() { Dispatcher.UIThread.Invoke(() => { var directory = Path.Combine( Path.GetTempPath(), $"atomui-previewer-file-revalidation-{Guid.NewGuid():N}"); var path = Path.Combine(directory, "image.png"); Directory.CreateDirectory(directory); try { File.WriteAllBytes(path, CreatePng(13, 17)); var items = new ObservableCollection { new(new FileImageSource(path)) }; var previewer = CreatePreviewer(items); using var host = new PreviewerHost(previewer); var firstEntry = previewer.EffectiveItems.ShouldNotBeNull().Single(); WaitUntil(() => firstEntry.ThumbnailState == ImageLoadState.Loaded, "initial file cover"); GetThumbnailOriginalSize(firstEntry).ShouldBe(new PixelSize(13, 17)); File.WriteAllBytes(path, CreatePng(29, 31)); File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(2)); items.Clear(); items.Add(new ImagePreviewItem(new FileImageSource(path))); var secondEntry = previewer.EffectiveItems.ShouldNotBeNull().Single(); WaitUntil(() => secondEntry.ThumbnailState == ImageLoadState.Loaded, "replaced file cover"); GetThumbnailOriginalSize(secondEntry).ShouldBe(new PixelSize(29, 31)); } finally { Directory.Delete(directory, recursive: true); } }); } [Fact] public void Open_Previewer_Loads_Replacement_Enumerable_Current_Item() { Dispatcher.UIThread.Invoke(() => { var first = new TestImage(); var second = new TestImage(); var previewer = CreatePreviewer(new ImagePreviewItem(new BorrowedImageSource(first))); using var host = new PreviewerHost(previewer); previewer.OpenDialog(); WaitUntil(() => previewer.IsCurrentLoaded, "initial current item"); previewer.ItemsSource = [ new ImagePreviewItem(new BorrowedImageSource(second)) ]; WaitUntil(() => previewer.IsCurrentLoaded, "replacement current item"); previewer.CurrentItem.ShouldNotBeNull().Source.ShouldBeSameAs( previewer.ItemsSource.ShouldNotBeNull().Single().Source); previewer.EffectiveItems.ShouldNotBeNull().Single().FullImage.ShouldBeSameAs(second); }); } [Fact] public void Current_Index_Switch_Does_Not_Commit_The_Previous_Full_Request() { Dispatcher.UIThread.Invoke(() => { var firstStarted = NewSignal(); var releaseFirst = NewSignal(); var firstSource = new StreamImageSource( async token => { firstStarted.TrySetResult(); await releaseFirst.Task.WaitAsync(token); return new MemoryStream(CreatePng(32, 32)); }, $"preview-switch-first-{Guid.NewGuid():N}", "v1"); var secondImage = new TestImage(); var previewer = CreatePreviewer( new ImagePreviewItem(firstSource), new ImagePreviewItem(new BorrowedImageSource(secondImage))); using var host = new PreviewerHost(previewer); previewer.OpenDialog(); WaitUntil(() => firstStarted.Task.IsCompleted, "first full request start"); previewer.CurrentIndex = 1; WaitUntil( () => previewer.IsCurrentLoaded && ReferenceEquals(previewer.EffectiveItems![1].FullImage, secondImage), "second current full request"); releaseFirst.TrySetResult(); WaitUntil(() => previewer.EffectiveItems![0].FullState != ImageLoadState.Loading, "cancelled first full request cleanup"); var entries = previewer.EffectiveItems.ShouldNotBeNull(); previewer.CurrentIndex.ShouldBe(1); previewer.CurrentItem.ShouldBeSameAs(previewer.ItemsSource!.ElementAt(1)); entries[1].FullImage.ShouldBeSameAs(secondImage); }); } [Fact] public void Open_Previewer_Loads_Current_Item_After_Observable_Reset() { Dispatcher.UIThread.Invoke(() => { var first = new TestImage(); var second = new TestImage(); var items = new ResettableCollection( [ new ImagePreviewItem(new BorrowedImageSource(first)) ]); var previewer = CreatePreviewer(items); using var host = new PreviewerHost(previewer); previewer.OpenDialog(); WaitUntil(() => previewer.IsCurrentLoaded, "initial current item"); items.ResetWith( [ new ImagePreviewItem(new BorrowedImageSource(second)) ]); WaitUntil(() => previewer.IsCurrentLoaded, "reset current item"); previewer.EffectiveItems.ShouldNotBeNull().Single().FullImage.ShouldBeSameAs(second); }); } [Fact] public void Observable_Collection_Operations_Preserve_Or_Dispose_The_Correct_Entries() { Dispatcher.UIThread.Invoke(() => { var first = CreateItem("first"); var second = CreateItem("second"); var third = CreateItem("third"); var items = new ObservableCollection([first, second, third]); var previewer = CreatePreviewer(items); using var host = new PreviewerHost(previewer); var initial = previewer.EffectiveItems.ShouldNotBeNull().ToArray(); var added = CreateItem("added"); items.Insert(1, added); previewer.EffectiveItems.ShouldNotBeNull()[0].ShouldBeSameAs(initial[0]); previewer.EffectiveItems[1].Item.ShouldBeSameAs(added); previewer.EffectiveItems[2].ShouldBeSameAs(initial[1]); previewer.EffectiveItems[3].ShouldBeSameAs(initial[2]); items.Move(2, 0); previewer.EffectiveItems[0].ShouldBeSameAs(initial[1]); var replacedEntry = previewer.EffectiveItems[1]; var replacement = CreateItem("replacement"); items[1] = replacement; previewer.EffectiveItems[1].Item.ShouldBeSameAs(replacement); Should.Throw(() => replacedEntry.LoadFull(16, 16, ImageRequestPriority.Critical)); var removedEntry = previewer.EffectiveItems[2]; items.RemoveAt(2); Should.Throw(() => removedEntry.LoadThumbnail(16, 16, ImageRequestPriority.High)); previewer.EffectiveItems.Count.ShouldBe(3); }); } [Fact] public void Detach_Releases_Entries_Unsubscribes_Collection_And_Reattach_Reconciles_Changes() { Dispatcher.UIThread.Invoke(() => { var full = new TestImage(); var thumbnail = new TestImage(); var first = new ImagePreviewItem(new BorrowedImageSource(full)) { ThumbnailSource = new BorrowedImageSource(thumbnail) }; var items = new ObservableCollection([first]); var previewer = CreatePreviewer(items); using var host = new PreviewerHost(previewer); previewer.RequestPreviewLoads(); var entry = previewer.EffectiveItems.ShouldNotBeNull().Single(); WaitUntil( () => entry.FullState == ImageLoadState.Loaded && entry.ThumbnailState == ImageLoadState.Loaded, "full and thumbnail loads"); host.Detach(); entry.FullImage.ShouldBeNull(); entry.ThumbnailImage.ShouldBeNull(); entry.FullState.ShouldBe(ImageLoadState.Idle); entry.ThumbnailState.ShouldBe(ImageLoadState.Idle); items.Add(CreateItem("added-while-detached")); previewer.EffectiveItems.Count.ShouldBe(1); host.Attach(previewer); WaitUntil(() => previewer.EffectiveItems?.Count == 2, "reattach collection reconciliation"); }); } [Fact] public void Entries_Hold_Loading_State_Independently_From_The_Immutable_Item() { Dispatcher.UIThread.Invoke(() => { var image = new TestImage(); var item = new ImagePreviewItem(new BorrowedImageSource(image)); using var first = new ImagePreviewEntry(item); using var second = new ImagePreviewEntry(item); first.LoadFull(16, 16, ImageRequestPriority.Critical); WaitUntil(() => first.FullState == ImageLoadState.Loaded, "first entry load"); first.FullImage.ShouldBeSameAs(image); second.FullImage.ShouldBeNull(); second.FullState.ShouldBe(ImageLoadState.Idle); item.GetType().GetProperties().Select(property => property.Name) .ShouldNotContain(nameof(ImagePreviewEntry.FullState)); }); } [Fact] public void Dispose_Notifies_Subscribers_To_Drop_Image_References() { Dispatcher.UIThread.Invoke(() => { var image = new TestImage(); var item = new ImagePreviewItem(new BorrowedImageSource(image)) { ThumbnailSource = new BorrowedImageSource(new TestImage()) }; var entry = new ImagePreviewEntry(item); var fullImageReset = false; var fullStateReset = false; var thumbnailImageReset = false; entry.PropertyChanged += (_, args) => { if (args.PropertyName == nameof(ImagePreviewEntry.FullImage)) { fullImageReset = true; } if (args.PropertyName == nameof(ImagePreviewEntry.FullState)) { fullStateReset = true; } if (args.PropertyName == nameof(ImagePreviewEntry.ThumbnailImage)) { thumbnailImageReset = true; } }; entry.LoadFull(16, 16, ImageRequestPriority.Critical); entry.LoadThumbnail(16, 16, ImageRequestPriority.High); WaitUntil(() => entry.FullState == ImageLoadState.Loaded && entry.ThumbnailState == ImageLoadState.Loaded, "entry loads"); // 排除加载阶段 CommitFull/CommitThumbnail 的常规通知,只观察 Dispose 的重置通知 fullImageReset = false; fullStateReset = false; thumbnailImageReset = false; entry.Dispose(); entry.FullImage.ShouldBeNull(); entry.ThumbnailImage.ShouldBeNull(); fullImageReset.ShouldBeTrue(); fullStateReset.ShouldBeTrue(); thumbnailImageReset.ShouldBeTrue(); }); } [Fact] public void Cover_Size_Change_Upgrades_The_Thumbnail_Decode_Bucket() { Dispatcher.UIThread.Invoke(() => { var source = new BytesImageSource( CreatePng(128, 128), $"cover-size-{Guid.NewGuid():N}"); var previewer = new global::AtomUI.Desktop.Controls.ImagePreviewer { CoverWidth = 16, CoverHeight = 16, ItemsSource = [new ImagePreviewItem(source)] }; using var host = new PreviewerHost(previewer); var entry = previewer.EffectiveItems.ShouldNotBeNull().Single(); WaitUntil( () => entry.ThumbnailState == ImageLoadState.Loaded, "initial thumbnail load"); GetThumbnailPixelSize(entry).ShouldBe(new PixelSize(16, 16)); previewer.CoverWidth = 96; previewer.CoverHeight = 96; WaitUntil( () => entry.ThumbnailState == ImageLoadState.Loaded && GetThumbnailPixelSize(entry) == new PixelSize(96, 96), "upgraded thumbnail load"); }); } [Fact] public void Auto_Sized_Cover_Upgrades_From_The_Placeholder_To_Final_Bounds() { Dispatcher.UIThread.Invoke(() => { var source = new BytesImageSource( CreatePng(512, 512), $"auto-sized-cover-{Guid.NewGuid():N}"); var previewer = new global::AtomUI.Desktop.Controls.ImagePreviewer { Width = 200, ItemsSource = [new ImagePreviewItem(source)] }; using var host = new PreviewerHost(previewer); var entry = previewer.EffectiveItems.ShouldNotBeNull().Single(); WaitUntil( () => entry.ThumbnailState == ImageLoadState.Loaded && GetThumbnailPixelSize(entry).Width >= 200 && GetThumbnailPixelSize(entry).Height >= 200, "auto-sized cover final decode bucket"); GetThumbnailPixelSize(entry).ShouldBe(new PixelSize(208, 208)); }); } [Fact] public void Repeating_The_Same_Thumbnail_Request_Is_Idempotent() { Dispatcher.UIThread.Invoke(() => { var reads = 0; var bytes = CreatePng(128, 128); var source = new StreamImageSource( _ => { reads++; return ValueTask.FromResult(new MemoryStream(bytes)); }, $"thumbnail-idempotency-{Guid.NewGuid():N}", "v1"); var item = new ImagePreviewItem(source) { RequestOptions = new ImageRequestOptions { CacheStorage = ImageCacheStoragePolicy.None } }; using var entry = new ImagePreviewEntry(item); entry.LoadThumbnail(32, 32, ImageRequestPriority.High); WaitUntil(() => entry.ThumbnailState == ImageLoadState.Loaded, "initial thumbnail request"); reads.ShouldBe(1); entry.LoadThumbnail(32, 32, ImageRequestPriority.High); WaitUntil(() => entry.ThumbnailState != ImageLoadState.Loading, "repeated thumbnail request"); reads.ShouldBe(1); GetThumbnailPixelSize(entry).ShouldBe(new PixelSize(32, 32)); }); } [Fact] public void Smaller_Thumbnail_Request_Reuses_The_Existing_Decode() { Dispatcher.UIThread.Invoke(() => { var reads = 0; var bytes = CreatePng(512, 256); var source = new StreamImageSource( _ => { reads++; return ValueTask.FromResult(new MemoryStream(bytes)); }, $"thumbnail-downsize-{Guid.NewGuid():N}", "v1"); var item = new ImagePreviewItem(source) { RequestOptions = new ImageRequestOptions { CacheStorage = ImageCacheStoragePolicy.None } }; using var entry = new ImagePreviewEntry(item); entry.LoadThumbnail(208, 208, ImageRequestPriority.High); WaitUntil(() => entry.ThumbnailState == ImageLoadState.Loaded, "initial thumbnail request"); entry.LoadThumbnail(208, 112, ImageRequestPriority.High); Dispatcher.UIThread.RunJobs(); reads.ShouldBe(1); entry.ThumbnailState.ShouldBe(ImageLoadState.Loaded); }); } [Fact] public void Svg_Cover_And_Full_Request_Reuse_The_Same_Vector_Decode() { Dispatcher.UIThread.Invoke(() => { var reads = 0; var bytes = Encoding.UTF8.GetBytes( ""); var source = new StreamImageSource( _ => { reads++; return ValueTask.FromResult(new MemoryStream(bytes)); }, $"preview-vector-{Guid.NewGuid():N}", "v1"); var previewer = CreatePreviewer(new ImagePreviewItem(source)); using var host = new PreviewerHost(previewer); var entry = previewer.EffectiveItems.ShouldNotBeNull().Single(); WaitUntil(() => previewer.IsCoverLoaded, "SVG cover load"); previewer.OpenDialog(); WaitUntil(() => previewer.IsCurrentLoaded, "SVG full load"); reads.ShouldBe(1); entry.FullImage.ShouldBeSameAs(entry.ThumbnailImage); entry.FullOrigin.ShouldBe(ImageLoadOrigin.DecodedMemory); entry.FullImage.ShouldNotBeNull().Size.ShouldBe(new Size(128, 96)); }); } [Fact] public void Auto_Sized_Rectangular_Cover_Settles_After_The_Initial_Load() { Dispatcher.UIThread.Invoke(() => { var reads = 0; var bytes = CreatePng(512, 256); var source = new StreamImageSource( _ => { reads++; return ValueTask.FromResult(new MemoryStream(bytes)); }, $"auto-cover-settle-{Guid.NewGuid():N}", "v1"); var previewer = new global::AtomUI.Desktop.Controls.ImagePreviewer { Width = 200, ItemsSource = [ new ImagePreviewItem(source) { RequestOptions = new ImageRequestOptions { CacheStorage = ImageCacheStoragePolicy.None } } ] }; using var host = new PreviewerHost(previewer); WaitUntil(() => previewer.IsCoverLoaded, "auto-sized rectangular cover"); for (var index = 0; index < 10; index++) { Dispatcher.UIThread.RunJobs(); } reads.ShouldBeLessThanOrEqualTo(2); previewer.Bounds.Width.ShouldBe(200); previewer.Bounds.Height.ShouldBeGreaterThan(0); }); } [Fact] public void Width_Only_Cover_Decode_Leaves_The_Auto_Height_Unbounded() { Dispatcher.UIThread.Invoke(() => { var source = new BytesImageSource( CreatePng(1004, 986), $"width-only-cover-{Guid.NewGuid():N}"); var previewer = new global::AtomUI.Desktop.Controls.ImagePreviewer { Width = 200, ItemsSource = [new ImagePreviewItem(source)] }; using var host = new PreviewerHost(previewer, renderScaling: 2); var entry = previewer.EffectiveItems.ShouldNotBeNull().Single(); WaitUntil(() => entry.ThumbnailState == ImageLoadState.Loaded, "width-only cover decode"); GetThumbnailResultRequestSize(entry).ShouldBe(new PixelSize(400, 0)); GetThumbnailPixelSize(entry).Width.ShouldBe(400); }); } [Fact] public void Thumbnail_Upgrade_Keeps_The_Previous_Image_Until_Replacement_Succeeds() { Dispatcher.UIThread.Invoke(() => { var reads = 0; var upgradeStarted = NewSignal(); var releaseUpgrade = NewSignal(); var bytes = CreatePng(128, 128); var source = new StreamImageSource( async token => { if (Interlocked.Increment(ref reads) == 2) { upgradeStarted.TrySetResult(); await releaseUpgrade.Task.WaitAsync(token); } return new MemoryStream(bytes); }, $"thumbnail-upgrade-{Guid.NewGuid():N}", "v1"); var item = new ImagePreviewItem(source) { RequestOptions = new ImageRequestOptions { CacheStorage = ImageCacheStoragePolicy.None } }; using var entry = new ImagePreviewEntry(item); entry.LoadThumbnail(16, 16, ImageRequestPriority.High); WaitUntil(() => entry.ThumbnailState == ImageLoadState.Loaded, "initial thumbnail"); var previous = entry.ThumbnailImage.ShouldNotBeNull(); GetThumbnailPixelSize(entry).ShouldBe(new PixelSize(16, 16)); entry.LoadThumbnail(96, 96, ImageRequestPriority.High); WaitUntil(() => upgradeStarted.Task.IsCompleted, "thumbnail upgrade start"); entry.ThumbnailState.ShouldBe(ImageLoadState.Loading); entry.ThumbnailImage.ShouldBeSameAs(previous); GetThumbnailPixelSize(entry).ShouldBe(new PixelSize(16, 16)); releaseUpgrade.TrySetResult(); WaitUntil( () => entry.ThumbnailState == ImageLoadState.Loaded && GetThumbnailPixelSize(entry) == new PixelSize(96, 96), "thumbnail upgrade completion"); entry.ThumbnailImage.ShouldNotBeSameAs(previous); }); } [Fact] public void Current_Item_Fallback_Does_Not_Change_Other_Item_Failure_State() { Dispatcher.UIThread.Invoke(() => { var fallback = new TestImage(); var current = new ImagePreviewItem( new BytesImageSource(new byte[] { 1 }, "current-primary")) { FallbackSource = new BorrowedImageSource(fallback) }; var neighbor = new ImagePreviewItem( new BytesImageSource(new byte[] { 2 }, "neighbor-primary")); var previewer = new TestPreviewer { Width = 96, Height = 96, PreloadCount = 1, ItemsSource = new[] { current, neighbor } }; ImagePreviewOpenedEventArgs? opened = null; ImagePreviewFailedEventArgs? failed = null; previewer.ImageOpened += (_, args) => opened = args; previewer.ImageFailed += (_, args) => failed = args; using var host = new PreviewerHost(previewer); previewer.RequestPreviewLoads(); var entries = previewer.EffectiveItems.ShouldNotBeNull(); WaitUntil( () => entries[0].FullState == ImageLoadState.Loaded && entries[1].FullState == ImageLoadState.Failed, "fallback and neighbor terminal states"); entries[0].FullImage.ShouldBeSameAs(fallback); entries[1].FullImage.ShouldBeNull(); opened.ShouldNotBeNull().Item.ShouldBeSameAs(current); failed.ShouldBeNull(); }); } [Fact] public void Reload_Methods_Only_Restart_The_Target_Entry() { Dispatcher.UIThread.Invoke(() => { var firstReads = 0; var secondReads = 0; var first = new ImagePreviewItem(CreateFailingStreamSource("first", () => firstReads++)); var second = new ImagePreviewItem(CreateFailingStreamSource("second", () => secondReads++)); var previewer = new TestPreviewer { Width = 96, Height = 96, PreloadCount = 1, ItemsSource = new[] { first, second } }; using var host = new PreviewerHost(previewer); previewer.RequestPreviewLoads(); var entries = previewer.EffectiveItems.ShouldNotBeNull(); WaitUntil( () => entries.All(entry => entry.FullState == ImageLoadState.Failed), "initial failed reads"); firstReads.ShouldBe(1); secondReads.ShouldBe(1); previewer.ReloadItem(1); WaitUntil(() => secondReads == 2 && entries[1].FullState == ImageLoadState.Failed, "item reload"); firstReads.ShouldBe(1); previewer.ReloadCurrent(); WaitUntil(() => firstReads == 2 && entries[0].FullState == ImageLoadState.Failed, "current reload"); secondReads.ShouldBe(2); }); } [Fact] public void ReloadCover_Only_Restarts_The_Cover_Thumbnail_Channel() { Dispatcher.UIThread.Invoke(() => { var thumbnailReads = 0; var full = new TestImage(); var previewer = CreatePreviewer(new ImagePreviewItem(new BorrowedImageSource(full)) { ThumbnailSource = CreateFailingStreamSource("cover-thumbnail", () => thumbnailReads++) }); using var host = new PreviewerHost(previewer); var entry = previewer.EffectiveItems.ShouldNotBeNull().Single(); WaitUntil(() => previewer.IsCoverLoaded, "initial cover fallback"); thumbnailReads.ShouldBe(1); entry.FullState.ShouldBe(ImageLoadState.Idle); previewer.ReloadCover(); WaitUntil( () => thumbnailReads == 2 && previewer.CoverLoadState == ImageLoadState.Loaded, "cover reload"); entry.FullState.ShouldBe(ImageLoadState.Idle); entry.FullImage.ShouldBeNull(); }); } [Fact] public void Dialog_Close_Releases_Full_Leases_But_Keeps_Cover_Lease() { Dispatcher.UIThread.Invoke(() => { var full = new TestImage(); var thumbnail = new TestImage(); var previewer = new ClosableImagePreviewer { Width = 96, Height = 96, ItemsSource = [ new ImagePreviewItem(new BorrowedImageSource(full)) { ThumbnailSource = new BorrowedImageSource(thumbnail) } ] }; using var host = new PreviewerHost(previewer); WaitUntil(() => previewer.IsCoverLoaded, "cover load"); previewer.OpenDialog(); WaitUntil(() => previewer.IsCurrentLoaded, "full preview load"); var entry = previewer.EffectiveItems.ShouldNotBeNull().Single(); previewer.CloseForTest(); entry.FullImage.ShouldBeNull(); entry.FullState.ShouldBe(ImageLoadState.Idle); entry.ThumbnailImage.ShouldBeSameAs(thumbnail); entry.ThumbnailState.ShouldBe(ImageLoadState.Loaded); }); } [Fact] public void Scheduler_Starts_Current_Then_Cover_Then_Neighbor_When_The_Read_Slot_Opens() { Dispatcher.UIThread.Invoke(() => { var blockerStarted = NewSignal(); var releaseBlocker = NewSignal(); var criticalStarted = NewSignal(); var releaseCritical = NewSignal(); var highStarted = NewSignal(); var releaseHigh = NewSignal(); var preloadStarted = NewSignal(); var releasePreload = NewSignal(); var loader = Application.Current.ShouldNotBeNull().GetImageLoader(); var blockerTask = loader.LoadAsync(new ImageLoadRequest(CreateGatedSource( "blocker", blockerStarted, releaseBlocker))).AsTask(); WaitUntil(() => blockerStarted.Task.IsCompleted, "scheduler blocker"); var current = new ImagePreviewItem(CreateGatedSource( "critical", criticalStarted, releaseCritical)) { ThumbnailSource = CreateGatedSource("high", highStarted, releaseHigh) }; var neighbor = new ImagePreviewItem(CreateGatedSource( "preload", preloadStarted, releasePreload)); var previewer = CreatePreviewer(current, neighbor); previewer.PreloadCount = 1; using var host = new PreviewerHost(previewer); WaitUntil(() => previewer.CoverLoadState == ImageLoadState.Loading, "queued cover load"); previewer.RequestPreviewLoads(); try { releaseBlocker.TrySetResult(); WaitUntil(() => criticalStarted.Task.IsCompleted, "critical request start"); highStarted.Task.IsCompleted.ShouldBeFalse(); preloadStarted.Task.IsCompleted.ShouldBeFalse(); releaseCritical.TrySetResult(); WaitUntil(() => highStarted.Task.IsCompleted, "high request start"); preloadStarted.Task.IsCompleted.ShouldBeFalse(); releaseHigh.TrySetResult(); WaitUntil(() => preloadStarted.Task.IsCompleted, "preload request start"); } finally { releaseBlocker.TrySetResult(); releaseCritical.TrySetResult(); releaseHigh.TrySetResult(); releasePreload.TrySetResult(); } WaitUntil(() => blockerTask.IsCompleted, "blocker completion"); blockerTask.GetAwaiter().GetResult().Dispose(); }); } [Fact] public void ImageSwitchMode_Defaults_To_Immediate() { Dispatcher.UIThread.Invoke(() => { var previewer = CreatePreviewer(); previewer.ImageSwitchMode.ShouldBe(ImageSwitchMode.Immediate); previewer.ImageSwitchMode = ImageSwitchMode.WaitForLoaded; previewer.ImageSwitchMode.ShouldBe(ImageSwitchMode.WaitForLoaded); }); } [Fact] public void Dialog_WaitForLoaded_Holds_The_Previous_Image_Until_The_Next_Load_Completes() { Dispatcher.UIThread.Invoke(() => { var first = new TestImage(); var secondStarted = NewSignal(); var releaseSecond = NewSignal(); var secondSource = new StreamImageSource( async token => { secondStarted.TrySetResult(); await releaseSecond.Task.WaitAsync(token); return new MemoryStream(CreatePng(32, 32)); }, $"dialog-hold-{Guid.NewGuid():N}", "v1"); var previewer = new TestPreviewer { Width = 96, Height = 96, PreloadCount = 0, ImageSwitchMode = ImageSwitchMode.WaitForLoaded, ItemsSource = new[] { new ImagePreviewItem(new BorrowedImageSource(first)), new ImagePreviewItem(secondSource) } }; using var host = new PreviewerHost(previewer); var entries = previewer.EffectiveItems.ShouldNotBeNull(); previewer.RequestPreviewLoads(); WaitUntil(() => entries[0].FullState == ImageLoadState.Loaded, "first full load"); var dialog = new ImagePreviewerDialog(new global::Avalonia.Controls.Window(), previewer); dialog.ItemsSource = entries; Dispatcher.UIThread.RunJobs(); dialog.CurrentImage.ShouldBeSameAs(first); entries[1].LoadFull(16, 16, ImageRequestPriority.Critical); dialog.CurrentIndex = 1; WaitUntil(() => secondStarted.Task.IsCompleted, "second load start"); Dispatcher.UIThread.RunJobs(); dialog.CurrentImage.ShouldBeSameAs(first); // 保留上一张 dialog.IsCurrentImageLoading.ShouldBeTrue(); releaseSecond.TrySetResult(); WaitUntil(() => entries[1].FullState == ImageLoadState.Loaded, "second full load"); WaitUntil(() => ReferenceEquals(dialog.CurrentImage, entries[1].FullImage), "display swap"); dialog.IsCurrentImageLoading.ShouldBeFalse(); dialog.Close(); dialog.CurrentImage.ShouldBeNull(); }); } [Fact] public void Dialog_Immediate_Clears_The_Previous_Image_And_Activates_Loading_Before_The_Request_Starts() { Dispatcher.UIThread.Invoke(() => { var first = new TestImage(); var secondStarted = NewSignal(); var releaseSecond = NewSignal(); var secondSource = new StreamImageSource( async token => { secondStarted.TrySetResult(); await releaseSecond.Task.WaitAsync(token); return new MemoryStream(CreatePng(32, 32)); }, $"dialog-immediate-{Guid.NewGuid():N}", "v1"); var previewer = new TestPreviewer { Width = 96, Height = 96, PreloadCount = 0, ImageSwitchMode = ImageSwitchMode.Immediate, ItemsSource = new[] { new ImagePreviewItem(new BorrowedImageSource(first)), new ImagePreviewItem(secondSource) } }; using var host = new PreviewerHost(previewer); var entries = previewer.EffectiveItems.ShouldNotBeNull(); previewer.RequestPreviewLoads(); WaitUntil(() => entries[0].FullState == ImageLoadState.Loaded, "first full load"); var dialog = new ImagePreviewerDialog(new global::Avalonia.Controls.Window(), previewer) { ItemsSource = entries }; Dispatcher.UIThread.RunJobs(); dialog.CurrentImage.ShouldBeSameAs(first); dialog.CurrentIndex = 1; Dispatcher.UIThread.RunJobs(); dialog.CurrentImage.ShouldBeNull(); dialog.IsCurrentImageLoading.ShouldBeTrue(); var viewer = dialog.Content.ShouldBeOfType(); viewer.Classes.Contains(":has-image").ShouldBeFalse(); viewer.Classes.Contains(":loading").ShouldBeTrue(); entries[1].LoadFull(16, 16, ImageRequestPriority.Critical); WaitUntil(() => secondStarted.Task.IsCompleted, "second load start"); dialog.CurrentImage.ShouldBeNull(); dialog.IsCurrentImageLoading.ShouldBeTrue(); releaseSecond.TrySetResult(); WaitUntil(() => entries[1].FullImage is not null && ReferenceEquals(dialog.CurrentImage, entries[1].FullImage), "second display"); dialog.IsCurrentImageLoading.ShouldBeFalse(); dialog.Close(); }); } [Fact] public void Dialog_Changing_To_Immediate_Recomputes_The_Pending_Target_Immediately() { Dispatcher.UIThread.Invoke(() => { var first = new TestImage(); var secondStarted = NewSignal(); var releaseSecond = NewSignal(); var secondSource = CreateGatedSource( $"dialog-mode-change-{Guid.NewGuid():N}", secondStarted, releaseSecond); var previewer = new TestPreviewer { Width = 96, Height = 96, PreloadCount = 0, ImageSwitchMode = ImageSwitchMode.WaitForLoaded, ItemsSource = [ new ImagePreviewItem(new BorrowedImageSource(first)), new ImagePreviewItem(secondSource) ] }; using var host = new PreviewerHost(previewer); var entries = previewer.EffectiveItems.ShouldNotBeNull(); previewer.OpenDialog(); WaitUntil(() => entries[0].FullState == ImageLoadState.Loaded, "first full load"); var dialog = FindOpenHost(previewer).ShouldBeOfType(); previewer.CurrentIndex = 1; WaitUntil(() => secondStarted.Task.IsCompleted, "second load start"); dialog.CurrentImage.ShouldBeSameAs(first); previewer.ImageSwitchMode = ImageSwitchMode.Immediate; Dispatcher.UIThread.RunJobs(); dialog.CurrentImage.ShouldBeNull(); dialog.IsCurrentImageLoading.ShouldBeTrue(); releaseSecond.TrySetResult(); previewer.IsOpen = false; }); } [Fact] public void Dialog_WaitForLoaded_Shows_Error_When_The_Target_Fails() { Dispatcher.UIThread.Invoke(() => { var first = new TestImage(); var previewer = new TestPreviewer { Width = 96, Height = 96, PreloadCount = 0, ImageSwitchMode = ImageSwitchMode.WaitForLoaded, ItemsSource = new[] { new ImagePreviewItem(new BorrowedImageSource(first)), new ImagePreviewItem(new BytesImageSource(new byte[] { 1 }, "dialog-fail")) } }; using var host = new PreviewerHost(previewer); var entries = previewer.EffectiveItems.ShouldNotBeNull(); previewer.RequestPreviewLoads(); WaitUntil(() => entries[0].FullState == ImageLoadState.Loaded, "first full load"); var dialog = new ImagePreviewerDialog(new global::Avalonia.Controls.Window(), previewer); dialog.ItemsSource = entries; Dispatcher.UIThread.RunJobs(); entries[1].LoadFull(16, 16, ImageRequestPriority.Critical); dialog.CurrentIndex = 1; WaitUntil(() => dialog.IsCurrentImageFailed && dialog.CurrentImage is null, "failed display"); }); } [Fact] public void Dialog_WaitForLoaded_Clears_The_Held_Image_When_Its_Source_Item_Is_Removed() { Dispatcher.UIThread.Invoke(() => { var first = new TestImage(); var secondStarted = NewSignal(); var releaseSecond = NewSignal(); var secondSource = new StreamImageSource( async token => { secondStarted.TrySetResult(); await releaseSecond.Task.WaitAsync(token); return new MemoryStream(CreatePng(32, 32)); }, $"dialog-remove-{Guid.NewGuid():N}", "v1"); var items = new ObservableCollection( [ new ImagePreviewItem(new BorrowedImageSource(first)), new ImagePreviewItem(secondSource) ]); var previewer = new TestPreviewer { Width = 96, Height = 96, PreloadCount = 0, ImageSwitchMode = ImageSwitchMode.WaitForLoaded, ItemsSource = items }; using var host = new PreviewerHost(previewer); var entries = previewer.EffectiveItems.ShouldNotBeNull(); previewer.RequestPreviewLoads(); WaitUntil(() => entries[0].FullState == ImageLoadState.Loaded, "first full load"); var dialog = new ImagePreviewerDialog(new global::Avalonia.Controls.Window(), previewer); dialog.ItemsSource = entries; Dispatcher.UIThread.RunJobs(); entries[1].LoadFull(16, 16, ImageRequestPriority.Critical); dialog.CurrentIndex = 1; WaitUntil(() => secondStarted.Task.IsCompleted, "second load start"); Dispatcher.UIThread.RunJobs(); dialog.CurrentImage.ShouldNotBeNull(); dialog.CurrentImage.ShouldBeSameAs(first); items.RemoveAt(0); // 移除保留帧源 WaitUntil(() => dialog.CurrentImage is null, "held image cleared after item removal"); releaseSecond.TrySetResult(); }); } [Fact] public void Rapid_Shared_Source_Switching_Never_Reports_Cancellation_As_Failure() { Dispatcher.UIThread.Invoke(() => { // 与 issue #450 demo 完全同构:两个 previewer 共享 item,每 tick 从磁盘复制出新文件、 // 追加、裁剪并切换到最新;PreloadCount=1 令邻项在 Preload→Critical 升级时取消旧请求。 // 同一图片源的请求在管线内合并为共享操作,竞争退出/拆除产生的内部取消 // 绝不能被 ImageLoader 误判为 InvalidSource 失败("canceled its own load operation")。 var root = Path.Combine(Path.GetTempPath(), $"issue450-regress-{Guid.NewGuid():N}"); var templateDir = Path.Combine(root, "tpl"); var feedDir = Path.Combine(root, "feed"); Directory.CreateDirectory(templateDir); Directory.CreateDirectory(feedDir); try { var png = CreatePng(800, 520); var templates = new string[60]; for (var t = 0; t < templates.Length; t++) { templates[t] = Path.Combine(templateDir, $"tpl-{t:D4}.png"); File.WriteAllBytes(templates[t], png); } var failures = new List(); for (var round = 0; round < 2; round++) { var items = new ObservableCollection(); var holdPreviewer = new TestPreviewer { Width = 96, Height = 96, PreloadCount = 1, ImageSwitchMode = ImageSwitchMode.WaitForLoaded, ItemsSource = items }; var immediatePreviewer = new TestPreviewer { Width = 96, Height = 96, PreloadCount = 1, ImageSwitchMode = ImageSwitchMode.Immediate, ItemsSource = items }; holdPreviewer.ImageFailed += (_, args) => failures.Add($"hold:{args.Error.Code}:{args.Error.Message}"); immediatePreviewer.ImageFailed += (_, args) => failures.Add($"immediate:{args.Error.Code}:{args.Error.Message}"); using var holdHost = new PreviewerHost(holdPreviewer); using var immediateHost = new PreviewerHost(immediatePreviewer); holdPreviewer.OpenDialog(); immediatePreviewer.OpenDialog(); var counter = 0; for (var tick = 0; tick < 150; tick++) { var feedFile = Path.Combine(feedDir, $"frame-{round}-{counter:D6}.png"); File.Copy(templates[counter % templates.Length], feedFile, true); counter++; items.Add(new ImagePreviewItem(ImageSource.Parse(new Uri(feedFile).AbsoluteUri))); if (items.Count > 60) { items.RemoveAt(0); } holdPreviewer.CurrentIndex = items.Count - 1; immediatePreviewer.CurrentIndex = items.Count - 1; Dispatcher.UIThread.RunJobs(); Thread.Sleep(2); } for (var spin = 0; spin < 40; spin++) { Dispatcher.UIThread.RunJobs(); Thread.Sleep(10); } } failures.ShouldBeEmpty(); } finally { Directory.Delete(root, true); } }); } [Fact] public void Internal_Shared_Cancel_Does_Not_Fail_The_Preview_Entry() { Dispatcher.UIThread.Invoke(() => { var started = NewSignal(); var release = NewSignal(); var source = new StreamImageSource( async token => { started.TrySetResult(); await release.Task.WaitAsync(token); return new MemoryStream(CreatePng(16, 16)); }, $"entry-foreign-cancel-{Guid.NewGuid():N}", "v1"); var entry = new ImagePreviewEntry(new ImagePreviewItem(source)); var failedRaised = false; entry.PropertyChanged += (_, args) => { if (args.PropertyName == nameof(ImagePreviewEntry.IsFullFailed) && entry.IsFullFailed) { failedRaised = true; } }; entry.LoadFull(16, 16, ImageRequestPriority.Critical); WaitUntil(() => started.Task.IsCompleted, "full load start"); // ClearCacheAsync(CancelInFlight) 以内部取消拆除在途共享操作,entry 自身 token 未取消; // 该取消必须按取消处理:不产生 Failed 提交与 FullError。 Application.Current.ShouldNotBeNull() .GetImageLoader() .ClearCacheAsync(new ImageCacheClearRequest()) .AsTask() .Wait(TimeSpan.FromSeconds(5)); WaitUntil(() => entry.FullState != ImageLoadState.Loading, "foreign cancel resolves"); entry.FullState.ShouldBe(ImageLoadState.Idle); entry.FullError.ShouldBeNull(); failedRaised.ShouldBeFalse(); release.TrySetResult(); entry.Dispose(); }); } [Fact] public void Cover_WaitForLoaded_Keeps_The_Previous_Image_While_The_New_Cover_Loads() { Dispatcher.UIThread.Invoke(() => { var first = new TestImage(); var secondStarted = NewSignal(); var releaseSecond = NewSignal(); var secondSource = new StreamImageSource( async token => { secondStarted.TrySetResult(); await releaseSecond.Task.WaitAsync(token); return new MemoryStream(CreatePng(32, 32)); }, $"cover-hold-{Guid.NewGuid():N}", "v1"); var previewer = new global::AtomUI.Desktop.Controls.ImagePreviewer { Width = 96, Height = 96, ImageSwitchMode = ImageSwitchMode.WaitForLoaded, ItemsSource = new[] { new ImagePreviewItem(new BorrowedImageSource(first)), new ImagePreviewItem(secondSource) } }; using var host = new PreviewerHost(previewer); WaitUntil(() => previewer.IsCoverLoaded, "first cover load"); previewer.EffectiveCoverImage.ShouldBeSameAs(first); previewer.CoverIndex = 1; WaitUntil(() => secondStarted.Task.IsCompleted, "second cover load start"); Dispatcher.UIThread.RunJobs(); previewer.EffectiveCoverImage.ShouldBeSameAs(first); // 封面保持上一张 previewer.IsCoverLoading.ShouldBeTrue(); releaseSecond.TrySetResult(); var entries = previewer.EffectiveItems.ShouldNotBeNull(); WaitUntil(() => previewer.CoverLoadState == ImageLoadState.Loaded && ReferenceEquals(previewer.EffectiveCoverImage, entries[1].ThumbnailImage), "cover swap after load"); }); } [Fact] public void Cover_Immediate_Clears_The_Previous_Image_And_Shows_Loading_While_The_New_Cover_Loads() { Dispatcher.UIThread.Invoke(() => { var first = new TestImage(); var secondStarted = NewSignal(); var releaseSecond = NewSignal(); var secondSource = new StreamImageSource( async token => { secondStarted.TrySetResult(); await releaseSecond.Task.WaitAsync(token); return new MemoryStream(CreatePng(32, 32)); }, $"cover-immediate-{Guid.NewGuid():N}", "v1"); var previewer = new global::AtomUI.Desktop.Controls.ImagePreviewer { Width = 96, Height = 96, ImageSwitchMode = ImageSwitchMode.Immediate, ItemsSource = new[] { new ImagePreviewItem(new BorrowedImageSource(first)), new ImagePreviewItem(secondSource) } }; using var host = new PreviewerHost(previewer); WaitUntil(() => previewer.IsCoverLoaded, "first cover load"); previewer.CoverIndex = 1; WaitUntil(() => secondStarted.Task.IsCompleted, "second cover load start"); Dispatcher.UIThread.RunJobs(); previewer.EffectiveCoverImage.ShouldBeNull(); previewer.IsCoverLoading.ShouldBeTrue(); releaseSecond.TrySetResult(); }); } [Fact] public void Cover_Changing_To_Immediate_Recomputes_The_Pending_Target_Immediately() { Dispatcher.UIThread.Invoke(() => { var first = new TestImage(); var secondStarted = NewSignal(); var releaseSecond = NewSignal(); var previewer = new global::AtomUI.Desktop.Controls.ImagePreviewer { Width = 96, Height = 96, ImageSwitchMode = ImageSwitchMode.WaitForLoaded, ItemsSource = [ new ImagePreviewItem(new BorrowedImageSource(first)), new ImagePreviewItem(CreateGatedSource( $"cover-mode-change-{Guid.NewGuid():N}", secondStarted, releaseSecond)) ] }; using var host = new PreviewerHost(previewer); WaitUntil(() => previewer.IsCoverLoaded, "first cover load"); previewer.CoverIndex = 1; WaitUntil(() => secondStarted.Task.IsCompleted, "second cover load start"); previewer.EffectiveCoverImage.ShouldBeSameAs(first); previewer.ImageSwitchMode = ImageSwitchMode.Immediate; Dispatcher.UIThread.RunJobs(); previewer.EffectiveCoverImage.ShouldBeNull(); previewer.IsCoverLoading.ShouldBeTrue(); releaseSecond.TrySetResult(); }); } [Fact] public void Cover_WaitForLoaded_Never_Flashes_A_Null_Image_During_Target_Switches() { Dispatcher.UIThread.Invoke(() => { var first = new TestImage(); var secondStarted = NewSignal(); var releaseSecond = NewSignal(); var secondSource = new StreamImageSource( async token => { secondStarted.TrySetResult(); await releaseSecond.Task.WaitAsync(token); return new MemoryStream(CreatePng(32, 32)); }, $"cover-no-flash-{Guid.NewGuid():N}", "v1"); var previewer = new global::AtomUI.Desktop.Controls.ImagePreviewer { Width = 96, Height = 96, ImageSwitchMode = ImageSwitchMode.WaitForLoaded, ItemsSource = new[] { new ImagePreviewItem(new BorrowedImageSource(first)), new ImagePreviewItem(secondSource) } }; using var host = new PreviewerHost(previewer); WaitUntil(() => previewer.IsCoverLoaded, "first cover load"); // 记录切换全程的 EffectiveCoverImage 通知值:不允许出现 null // (Idle 瞬态的 null 会经 mask 透明度过渡放大为可见闪烁) var observed = new List(); previewer.GetPropertyChangedObservable(global::AtomUI.Desktop.Controls.ImagePreviewer.EffectiveCoverImageProperty) .Subscribe(_ => observed.Add(previewer.EffectiveCoverImage)); previewer.CoverIndex = 1; WaitUntil(() => secondStarted.Task.IsCompleted, "second cover load start"); releaseSecond.TrySetResult(); WaitUntil(() => previewer.CoverLoadState == ImageLoadState.Loaded, "second cover load"); observed.ShouldNotBeEmpty(); observed.Count(v => v is null).ShouldBe(0); }); } [Fact] public void Same_Bucket_Priority_Upgrade_Promotes_The_Queued_Request_Without_Restarting_It() { Dispatcher.UIThread.Invoke(() => { var blockerStarted = NewSignal(); var releaseBlocker = NewSignal(); var promotedStarted = NewSignal(); var releasePromoted = NewSignal(); var competingStarted = NewSignal(); var releaseCompeting = NewSignal(); var loader = Application.Current.ShouldNotBeNull().GetImageLoader(); var blockerTask = loader.LoadAsync(new ImageLoadRequest(CreateGatedSource( $"priority-blocker-{Guid.NewGuid():N}", blockerStarted, releaseBlocker))).AsTask(); WaitUntil(() => blockerStarted.Task.IsCompleted, "priority blocker"); var promoted = new ImagePreviewEntry(new ImagePreviewItem(CreateGatedSource( $"priority-promoted-{Guid.NewGuid():N}", promotedStarted, releasePromoted))); var competing = new ImagePreviewEntry(new ImagePreviewItem(CreateGatedSource( $"priority-competing-{Guid.NewGuid():N}", competingStarted, releaseCompeting))); try { promoted.LoadFull(16, 16, ImageRequestPriority.Preload); competing.LoadFull(16, 16, ImageRequestPriority.High); // 同尺寸桶下 Preload→Critical 必须提升原在途 waiter,不能取消并重启源。 promoted.LoadFull(16, 16, ImageRequestPriority.Critical); releaseBlocker.TrySetResult(); WaitUntil( () => promotedStarted.Task.IsCompleted || competingStarted.Task.IsCompleted, "first queued request"); promotedStarted.Task.IsCompleted.ShouldBeTrue(); competingStarted.Task.IsCompleted.ShouldBeFalse(); } finally { releaseBlocker.TrySetResult(); releasePromoted.TrySetResult(); releaseCompeting.TrySetResult(); promoted.Dispose(); competing.Dispose(); } WaitUntil(() => blockerTask.IsCompleted, "priority blocker completion"); blockerTask.GetAwaiter().GetResult().Dispose(); }); } private static global::AtomUI.Desktop.Controls.ImagePreviewer CreatePreviewer( params ImagePreviewItem[] items) { return CreatePreviewer((IEnumerable)items); } private static global::AtomUI.Desktop.Controls.ImagePreviewer CreatePreviewer( IEnumerable items) { return new global::AtomUI.Desktop.Controls.ImagePreviewer { Width = 96, Height = 96, PreloadCount = 0, ItemsSource = items }; } private static ImagePreviewItem CreateItem(string key) { return new ImagePreviewItem(new BorrowedImageSource(new TestImage(), key)); } private static ImageSource CreateFailingStreamSource(string key, Action onOpen) { return new StreamImageSource( _ => { onOpen(); return ValueTask.FromResult(new MemoryStream(new byte[] { 1 })); }, key, "v1"); } private static ImageSource CreateGatedSource( string key, TaskCompletionSource started, TaskCompletionSource release) { return new StreamImageSource( async token => { started.TrySetResult(); await release.Task.WaitAsync(token); return new MemoryStream(new byte[] { 1 }); }, key, "v1"); } private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously); private static PixelSize GetThumbnailPixelSize(ImagePreviewEntry entry) { return entry.ThumbnailImage.ShouldBeOfType().PixelSize; } private static PixelSize? GetThumbnailResultRequestSize(ImagePreviewEntry entry) { return (PixelSize?)typeof(ImagePreviewEntry) .GetField( "_thumbnailResultRequestSize", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! .GetValue(entry); } private static PixelSize GetThumbnailOriginalSize(ImagePreviewEntry entry) { var result = (ImageLoadResult?)typeof(ImagePreviewEntry) .GetField( "_thumbnailResult", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! .GetValue(entry); result.ShouldNotBeNull(); return new PixelSize(result.OriginalPixelWidth, result.OriginalPixelHeight); } private static Control? FindOpenHost(AbstractImagePreviewer previewer) { var state = typeof(AbstractImagePreviewer) .GetField("_openState", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! .GetValue(previewer); if (state is null) { return null; } return state.GetType().GetProperty("DialogHost")?.GetValue(state) as Control ?? state.GetType().GetProperty("PreviewHost")?.GetValue(state) as Control; } 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 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 data) { Span 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 crc = stackalloc byte[4]; BinaryPrimitives.WriteUInt32BigEndian(crc, CalculateCrc32(crcBytes)); output.Write(crc); } private static uint CalculateCrc32(ReadOnlySpan 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 static void WaitUntil(Func 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 sealed class PreviewerHost : IDisposable { private readonly Avalonia.Controls.Window _window; internal PreviewerHost(Control previewer, double? renderScaling = null) { _window = new Avalonia.Controls.Window { Width = 320, Height = 240, Content = previewer }; _window.Show(); if (renderScaling is not null) { _window.SetRenderScaling(renderScaling.Value); _window.UpdateLayout(); } Dispatcher.UIThread.RunJobs(); } internal void Detach() { _window.Content = null; Dispatcher.UIThread.RunJobs(); } internal void Attach(Control previewer) { _window.Content = previewer; Dispatcher.UIThread.RunJobs(); } public void Dispose() { _window.Close(); Dispatcher.UIThread.RunJobs(); } } private sealed class TestPreviewer : AbstractImagePreviewer { } private sealed class ClosableImagePreviewer : global::AtomUI.Desktop.Controls.ImagePreviewer { internal void CloseForTest() { CloseDialog(); } } private sealed class ResettableCollection : ObservableCollection { internal ResettableCollection(IEnumerable items) : base(items) { } internal void ResetWith(IEnumerable items) { Items.Clear(); foreach (var item in items) { Items.Add(item); } OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } } private sealed class TestImage : IImage { public Size Size => new(24, 24); public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) { } } }