fix(ui): restore retail vitals and window interactions
This commit is contained in:
parent
4d84456c21
commit
1bd2b30291
36 changed files with 1462 additions and 86 deletions
|
|
@ -1,4 +1,6 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.Tests.UI.Layout;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
|
|
@ -354,7 +356,7 @@ public sealed class CursorFeedbackControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromRoot_worldProviderDrivesTargetCursor_whenUiNotHovered()
|
||||
public void UpdateFromRoot_worldProviderContinuesBehindNonItemUi()
|
||||
{
|
||||
var objects = SeedTargetObjects();
|
||||
var interaction = new ItemInteractionController(
|
||||
|
|
@ -376,11 +378,38 @@ public sealed class CursorFeedbackControllerTests
|
|||
// World hover (retail SmartBox found object) drives valid/invalid…
|
||||
Assert.Equal(CursorFeedbackKind.TargetValid, c.Update(root).Kind);
|
||||
|
||||
// …but UI occludes the world: hovering a plain panel → PENDING even
|
||||
// though the world provider would return a valid target.
|
||||
// UIElement_SmartBoxWrapper::FindObject @0x004E5430 only returns
|
||||
// early for UIElement_UIItem. Ordinary UI chrome falls through to
|
||||
// SmartBox::find_object, so it does not occlude the world pick.
|
||||
var panel = new UiPanel { Left = 390, Top = 290, Width = 40, Height = 40 };
|
||||
root.AddChild(panel);
|
||||
Assert.Equal(CursorFeedbackKind.TargetPending, c.Update(root).Kind);
|
||||
Assert.Equal(CursorFeedbackKind.TargetValid, c.Update(root).Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromRoot_toolbarBackpackRepresentsSelfAndShowsFoundCursorWithoutWorldHit()
|
||||
{
|
||||
ImportedLayout toolbar = FixtureLoader.LoadToolbar();
|
||||
using ToolbarController controller = ToolbarController.Bind(
|
||||
toolbar,
|
||||
new ClientObjectTable(),
|
||||
new ShortcutStore(),
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
useItem: static _ => { },
|
||||
playerGuid: () => Player);
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
root.AddChild(toolbar.Root);
|
||||
var backpack = Assert.IsType<UiButton>(toolbar.FindElement(0x100001B1u));
|
||||
System.Numerics.Vector2 position = backpack.ScreenPosition;
|
||||
root.OnMouseMove(
|
||||
(int)(position.X + backpack.Width * 0.5f),
|
||||
(int)(position.Y + backpack.Height * 0.5f));
|
||||
var c = new CursorFeedbackController(worldTargetProvider: () => 0u);
|
||||
|
||||
CursorFeedback feedback = c.Update(root);
|
||||
|
||||
Assert.Equal(CursorFeedbackKind.Default, feedback.Kind);
|
||||
Assert.Equal(RetailGlobalCursorKind.DefaultFound, feedback.GlobalKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -200,11 +200,11 @@ public class ChatLayoutConformanceTests
|
|||
[InlineData(0x10000698u)]
|
||||
[InlineData(0x10000699u)]
|
||||
[InlineData(0x1000069Au)]
|
||||
public void MountedChatWindow_LockedTwinBorderArt_DefaultsHidden(uint lockedTwinId)
|
||||
public void BoundChatWindow_LockedTwinBorderArt_SeedsUnlockedUntilRegistration(uint lockedTwinId)
|
||||
{
|
||||
// Register row AP-185: CH6a shows only the live (unlocked) grip/dragbar
|
||||
// border-art set by default, matching UiRoot.UiLocked's own false
|
||||
// default and avoiding a double-rendered border.
|
||||
// Bind seeds the unlocked skin for standalone layouts. Once registered,
|
||||
// RetailWindowLockPresentationController applies the canonical UiLocked
|
||||
// state; its real-fixture swap is covered by that controller's tests.
|
||||
var infos = FixtureLoader.LoadChatInfos();
|
||||
var layout = LayoutImporter.Build(infos, NoTex, null);
|
||||
var controller = ChatWindowController.Bind(
|
||||
|
|
|
|||
|
|
@ -1483,6 +1483,80 @@ public class InventoryControllerTests
|
|||
ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroundPack_rejectsContentsGrid_butEmptyPackSlotAcceptsAndPicksUpAtThatSlot()
|
||||
{
|
||||
const uint droppedPack = 0x700000C0u;
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Player,
|
||||
Type = ItemType.Creature,
|
||||
ItemsCapacity = 102,
|
||||
ContainersCapacity = 7,
|
||||
});
|
||||
SeedBag(objects, 0x500000C1u, slot: 0);
|
||||
SeedBag(objects, 0x500000C2u, slot: 1);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = droppedPack,
|
||||
Name = "Dropped Pack",
|
||||
Type = ItemType.Container,
|
||||
ItemsCapacity = 24,
|
||||
});
|
||||
var puts = new List<(uint Item, uint Container, int Placement)>();
|
||||
using var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(
|
||||
new InventoryTransactionState(objects)),
|
||||
new InteractionState(),
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
groundObjectId: () => droppedPack,
|
||||
backpackContainerId: () => Player,
|
||||
placeInBackpack: static (_, _, _) => { });
|
||||
using var controller = InventoryController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
() => Player,
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
strength: () => 100,
|
||||
selection: new SelectionState(),
|
||||
datFont: null,
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
puts.Add((item, container, placement)),
|
||||
itemInteraction: interaction);
|
||||
var source = new UiItemSlot { SourceKind = ItemDragSource.Ground };
|
||||
source.SetItem(droppedPack, 0u);
|
||||
var payload = new ItemDragPayload(
|
||||
droppedPack,
|
||||
ItemDragSource.Ground,
|
||||
SourceSlot: 0,
|
||||
SourceCell: source);
|
||||
|
||||
Assert.Equal(
|
||||
ItemDragAcceptance.Reject,
|
||||
controller.OnDragOver(grid, grid.GetItem(0)!, payload));
|
||||
controller.HandleDropRelease(grid, grid.GetItem(0)!, payload);
|
||||
Assert.Empty(puts);
|
||||
|
||||
UiItemSlot emptyPackSlot = containers.GetItem(2)!;
|
||||
Assert.Equal(0u, emptyPackSlot.ItemId);
|
||||
Assert.Equal(
|
||||
ItemDragAcceptance.Accept,
|
||||
controller.OnDragOver(containers, emptyPackSlot, payload));
|
||||
controller.HandleDropRelease(containers, emptyPackSlot, payload);
|
||||
|
||||
Assert.Equal(new[] { (droppedPack, Player, 2) }, puts);
|
||||
Assert.True(interaction.TryGetPendingBackpackPlacement(droppedPack, out var pending));
|
||||
Assert.Equal(Player, pending.ContainerId);
|
||||
Assert.Equal(2, pending.Placement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragLift_selectsItem_butKeepsItUntilServerConfirms()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.Core.Ui;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
|
@ -101,6 +103,111 @@ public sealed class RadarControllerTests
|
|||
Assert.False(layout.FindElement(RadarController.CoordinateContainerId)!.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_RealFixture_RootPointerLockCycleRoundTripsAuthorityAndMedia()
|
||||
{
|
||||
var layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadRadarInfos(),
|
||||
static file => (file, 8, 8),
|
||||
null);
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
root.AddChild(layout.Root);
|
||||
var state = UiRadarSnapshot.Empty with { UiLocked = false };
|
||||
var requestedLocks = new List<bool>();
|
||||
using var controller = RadarController.Bind(
|
||||
layout,
|
||||
() => state,
|
||||
setUiLocked: value =>
|
||||
{
|
||||
// Production's corrected contract: update the authoritative
|
||||
// character option before pushing the retained-root message.
|
||||
requestedLocks.Add(value);
|
||||
state = state with { UiLocked = value };
|
||||
root.UiLocked = value;
|
||||
});
|
||||
|
||||
var radar = Assert.IsType<UiRadar>(layout.Root);
|
||||
var lockButton = Assert.IsType<UiButton>(
|
||||
layout.FindElement(RadarController.LockButtonId));
|
||||
var dragButton = Assert.IsType<UiDatElement>(
|
||||
layout.FindElement(RadarController.DragButtonId));
|
||||
|
||||
radar.Refresh();
|
||||
Assert.Equal("UnlockedUI", lockButton.ActiveState);
|
||||
Assert.Equal(0x060074B8u, DrawnFaceFile(lockButton));
|
||||
Assert.True(lockButton.Visible);
|
||||
Assert.False(lockButton.ClickThrough);
|
||||
Assert.True(dragButton.Visible);
|
||||
Assert.True(radar.Draggable);
|
||||
|
||||
ClickThroughRoot(root, lockButton);
|
||||
radar.Refresh();
|
||||
|
||||
Assert.Equal([true], requestedLocks);
|
||||
Assert.True(root.UiLocked);
|
||||
Assert.Equal("LockedUI", lockButton.ActiveState);
|
||||
Assert.Equal(0x060074B7u, DrawnFaceFile(lockButton));
|
||||
Assert.True(lockButton.Visible);
|
||||
Assert.False(lockButton.ClickThrough);
|
||||
Assert.False(dragButton.Visible);
|
||||
Assert.False(radar.Draggable);
|
||||
|
||||
MoveAwayAndBack(root, lockButton);
|
||||
Assert.Equal("LockedUI", lockButton.ActiveState);
|
||||
Assert.Equal(0x060074B7u, DrawnFaceFile(lockButton));
|
||||
|
||||
// UiLocked suppresses move/resize only. The same radar button remains
|
||||
// hit-testable and must be able to publish the authoritative unlock.
|
||||
ClickThroughRoot(root, lockButton);
|
||||
radar.Refresh();
|
||||
|
||||
Assert.Equal([true, false], requestedLocks);
|
||||
Assert.False(root.UiLocked);
|
||||
Assert.Equal("UnlockedUI", lockButton.ActiveState);
|
||||
Assert.Equal(0x060074B8u, DrawnFaceFile(lockButton));
|
||||
Assert.True(lockButton.Visible);
|
||||
Assert.False(lockButton.ClickThrough);
|
||||
Assert.True(dragButton.Visible);
|
||||
Assert.True(radar.Draggable);
|
||||
|
||||
MoveAwayAndBack(root, lockButton);
|
||||
Assert.Equal("UnlockedUI", lockButton.ActiveState);
|
||||
Assert.Equal(0x060074B8u, DrawnFaceFile(lockButton));
|
||||
}
|
||||
|
||||
private static void ClickThroughRoot(UiRoot root, UiButton button)
|
||||
{
|
||||
Vector2 position = button.ScreenPosition;
|
||||
int x = (int)(position.X + button.Width * 0.5f);
|
||||
int y = (int)(position.Y + button.Height * 0.5f);
|
||||
root.OnMouseMove(x, y);
|
||||
root.OnMouseDown(UiMouseButton.Left, x, y);
|
||||
root.OnMouseUp(UiMouseButton.Left, x, y);
|
||||
}
|
||||
|
||||
private static void MoveAwayAndBack(UiRoot root, UiButton button)
|
||||
{
|
||||
root.OnMouseMove(700, 500);
|
||||
Vector2 position = button.ScreenPosition;
|
||||
root.OnMouseMove(
|
||||
(int)(position.X + button.Width * 0.5f),
|
||||
(int)(position.Y + button.Height * 0.5f));
|
||||
}
|
||||
|
||||
private static uint DrawnFaceFile(UiButton button)
|
||||
{
|
||||
var renderer = new TextRenderer(
|
||||
new RecordingGpuDevice(), new NullGpuFrameSource(), "unused");
|
||||
renderer.Begin(new Vector2(200f, 200f));
|
||||
button.DrawSelfAndChildren(new UiRenderContext(renderer, new Vector2(200f, 200f)));
|
||||
return Assert.Single(renderer.DebugSpriteSegments).Texture;
|
||||
}
|
||||
|
||||
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||||
{
|
||||
public AcDream.App.Rendering.Gpu.IGpuFrame? CurrentFrame => null;
|
||||
}
|
||||
|
||||
private static ImportedLayout BuildRadarLayout()
|
||||
{
|
||||
var root = new ElementInfo
|
||||
|
|
|
|||
|
|
@ -465,6 +465,73 @@ public class ToolbarControllerTests
|
|||
Assert.Equal("Normal", characterButton.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailFixture_inventoryButtonPressKeepsClosedFaceThenOpensDirectly()
|
||||
{
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadToolbarInfos(),
|
||||
static file => (file, 8, 8),
|
||||
null);
|
||||
var controller = ToolbarController.Bind(
|
||||
layout,
|
||||
new ClientObjectTable(),
|
||||
new ShortcutStore(),
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
useItem: _ => { });
|
||||
controller.BindPanelButtons(
|
||||
_ => true,
|
||||
panelId => controller.SetPanelOpen(panelId, open: true));
|
||||
var inventory = Assert.IsType<UiButton>(layout.FindElement(InventoryButtonId));
|
||||
|
||||
Assert.Equal("Normal", inventory.ActiveState);
|
||||
Assert.Equal(0x06004CF7u, DrawnFaceFile(inventory));
|
||||
|
||||
inventory.OnEvent(new UiEvent(
|
||||
inventory.EventId,
|
||||
inventory,
|
||||
UiEventType.MouseDown,
|
||||
Data1: 31,
|
||||
Data2: 29));
|
||||
|
||||
Assert.Equal("Normal_pressed", inventory.ActiveState);
|
||||
Assert.Equal(0x06004CF7u, DrawnFaceFile(inventory));
|
||||
|
||||
inventory.OnEvent(new UiEvent(
|
||||
inventory.EventId,
|
||||
inventory,
|
||||
UiEventType.MouseUp,
|
||||
Data1: 31,
|
||||
Data2: 29));
|
||||
|
||||
Assert.Equal("Highlight", inventory.ActiveState);
|
||||
Assert.Equal(0x06004CF8u, DrawnFaceFile(inventory));
|
||||
}
|
||||
|
||||
[InstalledDatFact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public void InstalledDat_inventoryButtonPressStateKeepsTheCurrentFace()
|
||||
{
|
||||
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
using var dats = new AcDream.App.Tests.BoundedTestDatCollection(
|
||||
datDirectory,
|
||||
DatReaderWriter.Options.DatAccessType.Read);
|
||||
ElementInfo root = Assert.IsType<ElementInfo>(
|
||||
LayoutImporter.ImportInfos(dats, 0x21000016u));
|
||||
ElementInfo inventory = FindInfo(root, InventoryButtonId);
|
||||
|
||||
Assert.Equal(1, inventory.States[UiButtonStateMachine.Normal].MediaCount);
|
||||
Assert.Equal(1, inventory.States[UiButtonStateMachine.Normal].ImageMediaCount);
|
||||
Assert.Equal(1, inventory.States[UiButtonStateMachine.NormalPressed].MediaCount);
|
||||
Assert.Equal(0, inventory.States[UiButtonStateMachine.NormalPressed].ImageMediaCount);
|
||||
Assert.Equal(1, inventory.States[UiButtonStateMachine.Highlight].MediaCount);
|
||||
Assert.Equal(1, inventory.States[UiButtonStateMachine.Highlight].ImageMediaCount);
|
||||
Assert.DoesNotContain("Normal_pressed", inventory.StateMedia.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailFixture_panelButtonsExposeExactDatPanelIds()
|
||||
{
|
||||
|
|
@ -1526,4 +1593,39 @@ public class ToolbarControllerTests
|
|||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9
|
||||
}
|
||||
|
||||
private static uint DrawnFaceFile(UiButton button)
|
||||
{
|
||||
var renderer = new AcDream.App.Rendering.TextRenderer(
|
||||
new AcDream.App.Tests.Rendering.Gpu.RecordingGpuDevice(),
|
||||
new NullGpuFrameSource(),
|
||||
"unused");
|
||||
renderer.Begin(new System.Numerics.Vector2(200f, 200f));
|
||||
button.DrawSelfAndChildren(new UiRenderContext(
|
||||
renderer,
|
||||
new System.Numerics.Vector2(200f, 200f)));
|
||||
return Assert.Single(renderer.DebugSpriteSegments).Texture;
|
||||
}
|
||||
|
||||
private sealed class NullGpuFrameSource
|
||||
: AcDream.App.Rendering.ICurrentGpuFrameSource
|
||||
{
|
||||
public AcDream.App.Rendering.Gpu.IGpuFrame? CurrentFrame => null;
|
||||
}
|
||||
|
||||
private static ElementInfo FindInfo(ElementInfo root, uint id)
|
||||
=> TryFindInfo(root, id)
|
||||
?? throw new InvalidOperationException($"Element 0x{id:X8} was not found.");
|
||||
|
||||
private static ElementInfo? TryFindInfo(ElementInfo root, uint id)
|
||||
{
|
||||
if (root.Id == id)
|
||||
return root;
|
||||
foreach (ElementInfo child in root.Children)
|
||||
{
|
||||
if (TryFindInfo(child, id) is { } found)
|
||||
return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1163,6 +1163,7 @@
|
|||
"File": 100693175,
|
||||
"DrawMode": 3
|
||||
},
|
||||
"MediaCount": 1,
|
||||
"Cursor": null,
|
||||
"Properties": {
|
||||
"Values": {}
|
||||
|
|
@ -1177,6 +1178,7 @@
|
|||
"File": 100693176,
|
||||
"DrawMode": 3
|
||||
},
|
||||
"MediaCount": 1,
|
||||
"Cursor": null,
|
||||
"Properties": {
|
||||
"Values": {}
|
||||
|
|
@ -1269,4 +1271,4 @@
|
|||
"LedCheckedSprite": 0,
|
||||
"LedUncheckedSprite": 0,
|
||||
"ScrollbarElementId": 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6569,6 +6569,8 @@
|
|||
"Name": "Normal_pressed",
|
||||
"PassToChildren": false,
|
||||
"IncorporationFlags": 0,
|
||||
"MediaCount": 1,
|
||||
"ImageMediaCount": 0,
|
||||
"Image": null,
|
||||
"Cursor": null,
|
||||
"Properties": {
|
||||
|
|
@ -6580,6 +6582,8 @@
|
|||
"Name": "Normal",
|
||||
"PassToChildren": false,
|
||||
"IncorporationFlags": 0,
|
||||
"MediaCount": 1,
|
||||
"ImageMediaCount": 1,
|
||||
"Image": {
|
||||
"File": 100682999,
|
||||
"DrawMode": 3
|
||||
|
|
@ -6594,6 +6598,8 @@
|
|||
"Name": "Highlight",
|
||||
"PassToChildren": false,
|
||||
"IncorporationFlags": 0,
|
||||
"MediaCount": 1,
|
||||
"ImageMediaCount": 1,
|
||||
"Image": {
|
||||
"File": 100683000,
|
||||
"DrawMode": 3
|
||||
|
|
@ -12574,4 +12580,4 @@
|
|||
"LedCheckedSprite": 0,
|
||||
"LedUncheckedSprite": 0,
|
||||
"ScrollbarElementId": 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreAll_WithoutSaveBack_WritesNothingToTheStore()
|
||||
public void RestoreAfterDisplayChange_WritesNothingToTheStore()
|
||||
{
|
||||
// The live display-change reload (#390) must not persist — retail
|
||||
// saves layouts only via @saveui/@saveautoui, and a mid-drag reload
|
||||
|
|
@ -346,19 +346,70 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
|
|||
using var persistence = new RetailWindowLayoutPersistence(
|
||||
root.WindowManager, store, () => "Alice", () => (800, 600));
|
||||
|
||||
persistence.RestoreAll(saveBack: false);
|
||||
persistence.RestoreAfterDisplayChange();
|
||||
|
||||
Assert.Null(store.LoadWindowLayout(
|
||||
"Alice", "800x600", WindowNames.Examination, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreAfterDisplayChange_RestoresGeometryButPreservesLiveVisibility()
|
||||
{
|
||||
// Retail's global-message-0xE auto-layout file contains only X/Y/W/H.
|
||||
// Our richer ambient settings schema also remembers visibility and
|
||||
// collapsed/maximized state, but replaying visibility during a
|
||||
// resolution change used to hide an open Options panel. Config's OnHidden reset
|
||||
// then restored the previous resolution, creating the user-observed
|
||||
// resize-out/resize-back blip.
|
||||
var store = new SettingsStore(PathName);
|
||||
store.SaveWindowLayout(
|
||||
"Alice",
|
||||
"1280x720",
|
||||
WindowNames.Options,
|
||||
new UiWindowLayout(
|
||||
X: 700f,
|
||||
Y: 200f,
|
||||
Width: 310f,
|
||||
Height: 400f,
|
||||
Visible: false,
|
||||
Collapsed: true,
|
||||
Maximized: true));
|
||||
|
||||
var root = new UiRoot { Width = 1280, Height = 720 };
|
||||
var state = new FakeWindowState();
|
||||
var lifecycle = new FakePanelController();
|
||||
RetailWindowHandle handle = Mount(
|
||||
root,
|
||||
WindowNames.Options,
|
||||
state,
|
||||
controller: lifecycle,
|
||||
width: 300f,
|
||||
height: 300f);
|
||||
using var persistence = new RetailWindowLayoutPersistence(
|
||||
root.WindowManager,
|
||||
store,
|
||||
() => "Alice",
|
||||
() => (1280, 720));
|
||||
|
||||
persistence.RestoreAfterDisplayChange();
|
||||
|
||||
Assert.Equal((700f, 200f, 310f, 400f),
|
||||
(handle.Left, handle.Top, handle.Width, handle.Height));
|
||||
Assert.True(handle.IsVisible);
|
||||
Assert.Equal(0, lifecycle.HiddenCount);
|
||||
Assert.Equal(1, state.RestoreCount);
|
||||
Assert.True(state.Restored.Collapsed);
|
||||
Assert.True(state.Restored.Maximized);
|
||||
}
|
||||
|
||||
private static RetailWindowHandle Mount(
|
||||
UiRoot root,
|
||||
string name,
|
||||
IRetainedWindowStateController? state = null,
|
||||
int authoredGeometryRevision = 0,
|
||||
float width = 200f,
|
||||
float height = 100f)
|
||||
float height = 100f,
|
||||
IRetainedPanelController? controller = null)
|
||||
{
|
||||
var frame = new UiPanel
|
||||
{
|
||||
|
|
@ -377,6 +428,7 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
|
|||
return root.RegisterWindow(
|
||||
name,
|
||||
frame,
|
||||
controller: controller,
|
||||
stateController: state,
|
||||
authoredGeometryRevision: authoredGeometryRevision);
|
||||
}
|
||||
|
|
@ -389,7 +441,23 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
|
|||
private sealed class FakeWindowState : IRetainedWindowStateController
|
||||
{
|
||||
public RetainedWindowState Restored { get; private set; }
|
||||
public int RestoreCount { get; private set; }
|
||||
public RetainedWindowState CaptureWindowState() => Restored;
|
||||
public void RestoreWindowState(RetainedWindowState state) => Restored = state;
|
||||
public void RestoreWindowState(RetainedWindowState state)
|
||||
{
|
||||
Restored = state;
|
||||
RestoreCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakePanelController : IRetainedPanelController
|
||||
{
|
||||
public int HiddenCount { get; private set; }
|
||||
|
||||
public void OnShown() { }
|
||||
|
||||
public void OnHidden() => HiddenCount++;
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,362 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class RetailWindowLockPresentationControllerTests
|
||||
{
|
||||
private static readonly uint[] LockedChatChromeIds =
|
||||
[
|
||||
0x10000693u, 0x10000694u, 0x10000695u, 0x10000696u,
|
||||
0x10000697u, 0x10000698u, 0x10000699u, 0x1000069Au,
|
||||
];
|
||||
|
||||
private static readonly uint[] LiveChatChromeIds =
|
||||
[
|
||||
0x1000069Bu, 0x1000069Cu, 0x1000069Du, 0x1000069Eu,
|
||||
0x1000069Fu, 0x100006A0u, 0x100006A1u, 0x100006A2u,
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void ExistingAndFutureNineSliceWindows_TrackCurrentLockPresentationExactly()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var existing = NewNineSlice();
|
||||
root.AddChild(existing);
|
||||
root.RegisterWindow("existing", existing);
|
||||
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
|
||||
Assert.True(existing.DrawResizeAffordances);
|
||||
Assert.True(existing.DrawCenterFill);
|
||||
|
||||
root.UiLocked = true;
|
||||
Assert.False(existing.DrawResizeAffordances);
|
||||
Assert.True(existing.DrawCenterFill);
|
||||
|
||||
var registeredWhileLocked = NewNineSlice();
|
||||
registeredWhileLocked.DrawCenterFill = false;
|
||||
root.AddChild(registeredWhileLocked);
|
||||
root.RegisterWindow("future", registeredWhileLocked);
|
||||
|
||||
Assert.False(registeredWhileLocked.DrawResizeAffordances);
|
||||
Assert.False(registeredWhileLocked.DrawCenterFill);
|
||||
|
||||
root.UiLocked = true;
|
||||
Assert.False(existing.DrawResizeAffordances);
|
||||
Assert.False(registeredWhileLocked.DrawResizeAffordances);
|
||||
|
||||
root.UiLocked = false;
|
||||
Assert.True(existing.DrawResizeAffordances);
|
||||
Assert.True(registeredWhileLocked.DrawResizeAffordances);
|
||||
Assert.True(existing.DrawCenterFill);
|
||||
Assert.False(registeredWhileLocked.DrawCenterFill);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LockedNineSlice_DrawsCenterAndEightBevelPieces_ButNoEightGripOverlays()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var frame = NewNineSlice();
|
||||
root.AddChild(frame);
|
||||
root.RegisterWindow("frame", frame);
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
|
||||
TextRenderer unlocked = Draw(frame);
|
||||
Assert.Equal(17, SpriteCallCount(unlocked));
|
||||
Assert.Equal(8, GripCallCount(unlocked));
|
||||
|
||||
root.UiLocked = true;
|
||||
TextRenderer locked = Draw(frame);
|
||||
|
||||
Assert.Equal(9, SpriteCallCount(locked));
|
||||
Assert.Equal(0, GripCallCount(locked));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CenterFill));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.TopEdge));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.BottomEdge));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.LeftEdge));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.RightEdge));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerTL));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerTR));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerBL));
|
||||
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerBR));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredChatChrome_SwapsLiveAndLockedSets_AndUnlockRestoresExactly()
|
||||
{
|
||||
var infos = FixtureLoader.LoadChatInfos();
|
||||
var layout = LayoutImporter.Build(infos, NoTex, null);
|
||||
_ = ChatWindowController.Bind(
|
||||
infos,
|
||||
layout,
|
||||
new ChatVM(new ChatLog()),
|
||||
() => NullCommandBus.Instance,
|
||||
new ChatWindowState(),
|
||||
null,
|
||||
null,
|
||||
NoTex);
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
root.AddChild(layout.Root);
|
||||
root.RegisterWindow("chat", layout.Root, layout.Root);
|
||||
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
|
||||
AssertChrome(layout, liveVisible: true);
|
||||
|
||||
root.UiLocked = true;
|
||||
AssertChrome(layout, liveVisible: false);
|
||||
|
||||
root.UiLocked = true;
|
||||
AssertChrome(layout, liveVisible: false);
|
||||
|
||||
root.UiLocked = false;
|
||||
AssertChrome(layout, liveVisible: true);
|
||||
|
||||
root.UiLocked = false;
|
||||
AssertChrome(layout, liveVisible: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredWindowRegisteredWhileLocked_StartsWithLockedChrome()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600, UiLocked = true };
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
var infos = FixtureLoader.LoadChatInfos();
|
||||
var layout = LayoutImporter.Build(infos, NoTex, null);
|
||||
_ = ChatWindowController.Bind(
|
||||
infos,
|
||||
layout,
|
||||
new ChatVM(new ChatLog()),
|
||||
() => NullCommandBus.Instance,
|
||||
new ChatWindowState(),
|
||||
null,
|
||||
null,
|
||||
NoTex);
|
||||
|
||||
root.AddChild(layout.Root);
|
||||
root.RegisterWindow("late-chat", layout.Root, layout.Root);
|
||||
|
||||
AssertChrome(layout, liveVisible: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FloatingChat_LockHidesAllResizeGrips_ButLeavesTitleDragElementVisible()
|
||||
{
|
||||
var layout = FixtureLoader.LoadFloatyChat();
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
root.AddChild(layout.Root);
|
||||
root.RegisterWindow("floaty", layout.Root, layout.Root);
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
UiElement? titleDrag = layout.FindElement(0x10000529u);
|
||||
Assert.NotNull(titleDrag);
|
||||
UiResizeGrip[] grips = DescendantsAndSelf(layout.Root).OfType<UiResizeGrip>().ToArray();
|
||||
Assert.Equal(8, grips.Length);
|
||||
Assert.All(grips, grip => Assert.True(grip.Visible));
|
||||
Assert.True(titleDrag.Visible);
|
||||
|
||||
root.UiLocked = true;
|
||||
|
||||
Assert.All(grips, grip => Assert.False(grip.Visible));
|
||||
Assert.True(titleDrag.Visible);
|
||||
|
||||
root.UiLocked = false;
|
||||
Assert.All(grips, grip => Assert.True(grip.Visible));
|
||||
Assert.True(titleDrag.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredChrome_MissingPairMembersAndUnrelatedDecoration_TransitionSafely()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var frame = new UiPanel { Width = 200, Height = 100 };
|
||||
var lockedOnly = new UiPanel { DatElementId = 0x10000633u, Visible = false };
|
||||
var liveOnly = new UiPanel { DatElementId = 0x1000063Cu, Visible = true };
|
||||
var unrelated = new UiPanel { DatElementId = 0x10000632u, Visible = true };
|
||||
var initiallyHiddenLive = new UiPanel { DatElementId = 0x1000063Du, Visible = false };
|
||||
frame.AddChild(lockedOnly);
|
||||
frame.AddChild(liveOnly);
|
||||
frame.AddChild(unrelated);
|
||||
frame.AddChild(initiallyHiddenLive);
|
||||
root.AddChild(frame);
|
||||
root.RegisterWindow("partial", frame);
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
|
||||
root.UiLocked = true;
|
||||
Assert.True(lockedOnly.Visible);
|
||||
Assert.False(liveOnly.Visible);
|
||||
Assert.True(unrelated.Visible);
|
||||
Assert.False(initiallyHiddenLive.Visible);
|
||||
|
||||
root.UiLocked = false;
|
||||
Assert.False(lockedOnly.Visible);
|
||||
Assert.True(liveOnly.Visible);
|
||||
Assert.True(unrelated.Visible);
|
||||
Assert.False(initiallyHiddenLive.Visible);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x10000633u, 0x1000063Bu)]
|
||||
[InlineData(0x10000643u, 0x1000064Bu)]
|
||||
[InlineData(0x10000653u, 0x1000065Bu)]
|
||||
[InlineData(0x10000663u, 0x1000066Bu)]
|
||||
[InlineData(0x10000673u, 0x1000067Bu)]
|
||||
[InlineData(0x10000683u, 0x1000068Bu)]
|
||||
[InlineData(0x10000693u, 0x1000069Bu)]
|
||||
[InlineData(0x100006A5u, 0x100006ADu)]
|
||||
public void EveryRetailAuthoredChromeBlock_SwapsAllEightMembers(
|
||||
uint lockedStart,
|
||||
uint liveStart)
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var frame = new UiPanel { Width = 200, Height = 100 };
|
||||
UiElement[] lockedChrome = Enumerable.Range(0, 8)
|
||||
.Select(i => new UiPanel { DatElementId = lockedStart + (uint)i })
|
||||
.Cast<UiElement>()
|
||||
.ToArray();
|
||||
UiElement[] liveChrome = Enumerable.Range(0, 8)
|
||||
.Select(i => new UiPanel { DatElementId = liveStart + (uint)i })
|
||||
.Cast<UiElement>()
|
||||
.ToArray();
|
||||
foreach (UiElement element in lockedChrome.Concat(liveChrome))
|
||||
frame.AddChild(element);
|
||||
root.AddChild(frame);
|
||||
root.RegisterWindow("authored", frame);
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
|
||||
Assert.All(lockedChrome, element => Assert.False(element.Visible));
|
||||
Assert.All(liveChrome, element => Assert.True(element.Visible));
|
||||
|
||||
root.UiLocked = true;
|
||||
Assert.All(lockedChrome, element => Assert.True(element.Visible));
|
||||
Assert.All(liveChrome, element => Assert.False(element.Visible));
|
||||
|
||||
root.UiLocked = false;
|
||||
Assert.All(lockedChrome, element => Assert.False(element.Visible));
|
||||
Assert.All(liveChrome, element => Assert.True(element.Visible));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmartBoxLiveOnlyChrome_HidesAndRestores_WithoutTouchingOtherType2OrType3Decoration()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var frame = new UiPanel { Width = 200, Height = 100 };
|
||||
UiElement[] smartBoxChrome = Enumerable.Range(0, 8)
|
||||
.Select(i => new UiPanel { DatElementId = 0x100006CAu + (uint)i })
|
||||
.Cast<UiElement>()
|
||||
.ToArray();
|
||||
var unrelatedType2 = new UiPanel { DatElementId = 0x10000529u };
|
||||
var unrelatedType3 = new UiPanel { DatElementId = 0x100006D2u };
|
||||
foreach (UiElement chrome in smartBoxChrome)
|
||||
frame.AddChild(chrome);
|
||||
frame.AddChild(unrelatedType2);
|
||||
frame.AddChild(unrelatedType3);
|
||||
root.AddChild(frame);
|
||||
root.RegisterWindow("smartbox", frame);
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
|
||||
root.UiLocked = true;
|
||||
Assert.All(smartBoxChrome, chrome => Assert.False(chrome.Visible));
|
||||
Assert.True(unrelatedType2.Visible);
|
||||
Assert.True(unrelatedType3.Visible);
|
||||
|
||||
root.UiLocked = false;
|
||||
Assert.All(smartBoxChrome, chrome => Assert.True(chrome.Visible));
|
||||
Assert.True(unrelatedType2.Visible);
|
||||
Assert.True(unrelatedType3.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LockedLateRegistration_AppliesChromeBeforeControllerOnShown()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600, UiLocked = true };
|
||||
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
|
||||
var frame = NewNineSlice();
|
||||
root.AddChild(frame);
|
||||
var observer = new OnShownObserver(() => !frame.DrawResizeAffordances);
|
||||
|
||||
root.RegisterWindow("late", frame, frame, observer);
|
||||
|
||||
Assert.True(observer.ObservedLockedChrome);
|
||||
Assert.Equal(1, observer.ShownCount);
|
||||
}
|
||||
|
||||
private static UiNineSlicePanel NewNineSlice() =>
|
||||
new(static id => (id, 5, 5)) { Width = 200, Height = 100 };
|
||||
|
||||
private static TextRenderer Draw(UiElement element)
|
||||
{
|
||||
var renderer = new TextRenderer(new RecordingGpuDevice(), new NullGpuFrameSource(), "unused");
|
||||
renderer.Begin(new Vector2(800f, 600f));
|
||||
element.DrawSelfAndChildren(new UiRenderContext(renderer, new Vector2(800f, 600f)));
|
||||
return renderer;
|
||||
}
|
||||
|
||||
private static int SpriteCallCount(TextRenderer renderer) =>
|
||||
renderer.DebugSpriteSegments.Sum(segment => segment.VertexCount / 6);
|
||||
|
||||
private static int CallsFor(TextRenderer renderer, uint texture) =>
|
||||
renderer.DebugSpriteSegments
|
||||
.Where(segment => segment.Texture == texture)
|
||||
.Sum(segment => segment.VertexCount / 6);
|
||||
|
||||
private static int GripCallCount(TextRenderer renderer) =>
|
||||
CallsFor(renderer, RetailChromeSprites.GripTop)
|
||||
+ CallsFor(renderer, RetailChromeSprites.GripBottom)
|
||||
+ CallsFor(renderer, RetailChromeSprites.GripLeft)
|
||||
+ CallsFor(renderer, RetailChromeSprites.GripRight)
|
||||
+ CallsFor(renderer, RetailChromeSprites.GripCorner);
|
||||
|
||||
private static IEnumerable<UiElement> DescendantsAndSelf(UiElement root)
|
||||
{
|
||||
yield return root;
|
||||
foreach (UiElement child in root.Children)
|
||||
foreach (UiElement descendant in DescendantsAndSelf(child))
|
||||
yield return descendant;
|
||||
}
|
||||
|
||||
private static void AssertChrome(ImportedLayout layout, bool liveVisible)
|
||||
{
|
||||
foreach (uint id in LiveChatChromeIds)
|
||||
{
|
||||
UiElement? element = layout.FindElement(id);
|
||||
Assert.NotNull(element);
|
||||
Assert.Equal(liveVisible, element.Visible);
|
||||
}
|
||||
foreach (uint id in LockedChatChromeIds)
|
||||
{
|
||||
UiElement? element = layout.FindElement(id);
|
||||
Assert.NotNull(element);
|
||||
Assert.Equal(!liveVisible, element.Visible);
|
||||
}
|
||||
}
|
||||
|
||||
private static (uint tex, int width, int height) NoTex(uint _) => (0u, 0, 0);
|
||||
|
||||
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||||
{
|
||||
public IGpuFrame? CurrentFrame => null;
|
||||
}
|
||||
|
||||
private sealed class OnShownObserver(Func<bool> observe) : IRetainedPanelController
|
||||
{
|
||||
public int ShownCount { get; private set; }
|
||||
public bool ObservedLockedChrome { get; private set; }
|
||||
|
||||
public void OnShown()
|
||||
{
|
||||
ShownCount++;
|
||||
ObservedLockedChrome = observe();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue