acdream/tests/AcDream.App.Tests/Composition/SwapchainRecreateViewportTargetTests.cs
Erik a7ae756b44 fix #387: window resize never recreated the Vulkan swapchain (stretch)
User report: resolution picks (and window drags) stretched the image
instead of changing the pixel count. Root cause: Campaign V slice V11
deleted the GL viewport target and left a null target, assuming the
driver's OUT_OF_DATE/SUBOPTIMAL acquire/present results would drive
swapchain recreation on resize. That is driver-dependent and
spec-insufficient — this machine's Windows AMD driver keeps presenting
the stale-extent swapchain scaled to the new window indefinitely, so
OnFramebufferResize only ever updated the camera aspect while every
pass (UI included) kept rendering at the old extent.

Fix: SwapchainRecreateViewportTarget implements the existing
IFramebufferViewportTarget seam for Vulkan and arms
VulkanGraphicsContext.RequestRecreate() on every resize event; the next
PrepareFrame rebuilds the swapchain at the live FramebufferSize (bursts
collapse to one recreation, stale events cannot install a stale extent,
minimised sizes stay gated by FramebufferResizeController).

Tests: SwapchainRecreateViewportTargetTests (target contract, size-
agnostic arming, null hook, controller-to-target end-to-end with the
minimised gate). Full Debug App suite 4,941/3 skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:05:13 +02:00

80 lines
3 KiB
C#

using AcDream.App.Composition;
using AcDream.App.Input;
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Composition;
/// <summary>
/// #387: the V11 null viewport target assumed the driver's OUT_OF_DATE/
/// SUBOPTIMAL results would drive swapchain recreation on window resize; on
/// Windows AMD the driver instead presents the stale-extent swapchain scaled
/// to the new window (the "resolution pick just stretches" 2026-08-13 user
/// gate report). These pin the replacement target's contract: every resize
/// event reaching the seam arms the context's frame-boundary recreation.
/// </summary>
public sealed class SwapchainRecreateViewportTargetTests
{
[Fact]
public void ResizeViewport_ArmsRecreation_OncePerEvent()
{
int armed = 0;
IFramebufferViewportTarget target =
new VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget(
() => armed++);
target.ResizeViewport(1024, 768);
Assert.Equal(1, armed);
// A second resize arms again — the context collapses bursts itself at
// the frame boundary (it re-reads the live FramebufferSize), so the
// target must never de-duplicate or latch.
target.ResizeViewport(800, 600);
Assert.Equal(2, armed);
}
[Fact]
public void ResizeViewport_IgnoresTheEventSize_ContextReadsLiveFramebuffer()
{
// The width/height are deliberately unused (doc on the target): the
// context reads the CURRENT framebuffer size at the frame boundary,
// so a stale event cannot install a stale extent. Pin that the arm
// fires regardless of the reported size values.
int armed = 0;
IFramebufferViewportTarget target =
new VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget(
() => armed++);
target.ResizeViewport(int.MaxValue, 1);
Assert.Equal(1, armed);
}
[Fact]
public void Constructor_RejectsNullRecreateHook()
{
Assert.Throws<ArgumentNullException>(() =>
new VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget(null!));
}
[Fact]
public void ResizeController_DeliversResizeEventsToTheBoundTarget()
{
// The end-to-end seam FramebufferResizeController.Resize →
// ResizeViewport → arm: the same event flow GameWindow's
// OnFramebufferResize drives in production. Zero-area (minimised)
// events are gated by the controller and must NOT arm.
int armed = 0;
var controller = new FramebufferResizeController(new ViewportAspectState());
controller.BindViewport(
new VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget(
() => armed++));
controller.Resize(1280, 720);
Assert.Equal(1, armed);
controller.Resize(0, 0); // minimised — gated, no arm
Assert.Equal(1, armed);
controller.Resize(1024, 768);
Assert.Equal(2, armed);
}
}