feat(chat): port retail client command families
Expand the typed client-command boundary across travel, character queries, local UI and layout controls, AFK and consent, emotes, friends, squelch and filters, and fill-components. Preserve retail packet layouts and queue ownership, import the confirmation dialog, and keep authoritative social state in Core. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
5a45a7ac7f
commit
9ea579bdd0
47 changed files with 6659 additions and 99 deletions
|
|
@ -1,4 +1,6 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
|
@ -6,23 +8,312 @@ namespace AcDream.App.Tests.UI;
|
|||
public sealed class ClientCommandControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void LifestoneRecall_ExecutesSuppliedSessionActionOnce()
|
||||
public void RecallAndQueryCommands_ExecuteTheirExactBindings()
|
||||
{
|
||||
int recalls = 0;
|
||||
var controller = new ClientCommandController(() => recalls++);
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls: calls);
|
||||
|
||||
Execute(ClientCommandId.LifestoneRecall);
|
||||
Execute(ClientCommandId.MarketplaceRecall);
|
||||
Execute(ClientCommandId.PkArenaRecall);
|
||||
Execute(ClientCommandId.PkLiteArenaRecall);
|
||||
Execute(ClientCommandId.HouseRecall);
|
||||
Execute(ClientCommandId.MansionRecall);
|
||||
Execute(ClientCommandId.QueryAge);
|
||||
Execute(ClientCommandId.QueryBirth);
|
||||
|
||||
Assert.Equal(
|
||||
["ls", "mp", "pka", "pla", "house", "mansion", "age", "birth"],
|
||||
calls);
|
||||
|
||||
void Execute(ClientCommandId id) => controller.Execute(
|
||||
new ExecuteClientCommandCmd(id, Arguments: string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PkArena_NonPk_ShowsRetailFailureWithoutSending()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var errors = new List<uint>();
|
||||
var controller = NewController(calls, errors, playerBitfield: 0x8u);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.LifestoneRecall, Arguments: string.Empty));
|
||||
ClientCommandId.PkArenaRecall, string.Empty));
|
||||
|
||||
Assert.Equal(1, recalls);
|
||||
Assert.Empty(calls);
|
||||
Assert.Equal([0x055Fu], errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PkLiteArena_NonPkLite_ShowsRetailFailureWithoutSending()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var errors = new List<uint>();
|
||||
var controller = NewController(calls, errors, playerBitfield: 0x8u);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.PkLiteArenaRecall, string.Empty));
|
||||
|
||||
Assert.Empty(calls);
|
||||
Assert.Equal([0x0560u], errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingPlayerDescription_DoesNotInventAClientRejection()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls, playerBitfield: null);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(
|
||||
ClientCommandId.PkArenaRecall, string.Empty));
|
||||
|
||||
Assert.Equal(["pka"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalPresentationCommands_UseApplicationServices()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var messages = new List<string>();
|
||||
var controller = NewController(calls, messages: messages);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ToggleFrameRate, ""));
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ToggleUiLock, ""));
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ShowVersion, ""));
|
||||
|
||||
Assert.Equal(["fps", "lock"], calls);
|
||||
Assert.Equal(["Client version 1.2.3"], messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Die_RequiresRetailConfirmationBeforeSuicide()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Die, ""));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"confirm:Do you really want to kill your character? You may drop items and accrue a vitae penalty.",
|
||||
"suicide",
|
||||
],
|
||||
calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatAndLayoutCommands_UseNamedAndAutomaticPersistenceBindings()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var messages = new List<string>();
|
||||
var controller = NewController(calls, messages: messages);
|
||||
|
||||
Execute(ClientCommandId.ClearChat, "all");
|
||||
Execute(ClientCommandId.SaveUi, "hunt");
|
||||
Execute(ClientCommandId.LoadUi, "hunt");
|
||||
Execute(ClientCommandId.SaveAutoUi, string.Empty);
|
||||
Execute(ClientCommandId.LoadAutoUi, string.Empty);
|
||||
Execute(ClientCommandId.SaveUi, "this-name-is-over-16-characters");
|
||||
|
||||
Assert.Equal(
|
||||
["clear:True", "saveui:hunt", "loadui:hunt", "saveautoui", "loadautoui"],
|
||||
calls);
|
||||
Assert.Equal(["The file name must be 16 characters or less."], messages);
|
||||
|
||||
void Execute(ClientCommandId id, string arguments) =>
|
||||
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AwayCommands_RespectCurrentModeAndPackRetailMessageForm()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var messages = new List<string>();
|
||||
var controller = NewController(calls, messages: messages, isAway: false);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "on"));
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "msg Stepped away"));
|
||||
|
||||
Assert.Equal(["afk:True", "afkmsg:Stepped away\n"], calls);
|
||||
Assert.Equal(["New AFK message set: Stepped away\n"], messages);
|
||||
|
||||
calls.Clear();
|
||||
controller = NewController(calls, isAway: true);
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "off"));
|
||||
Assert.Equal(["afk:False"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsentAndEmoteCommands_RouteTheirExactActions()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls, acceptsLootPermits: false);
|
||||
|
||||
Execute(ClientCommandId.Consent, "on");
|
||||
Execute(ClientCommandId.Consent, "who");
|
||||
Execute(ClientCommandId.Consent, "clear");
|
||||
Execute(ClientCommandId.Consent, "remove Alice Example");
|
||||
Execute(ClientCommandId.Emote, "waves happily");
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"consentmode:True",
|
||||
"consentwho",
|
||||
"consentclear",
|
||||
"consentremove:Alice Example",
|
||||
"emote:waves happily",
|
||||
],
|
||||
calls);
|
||||
|
||||
void Execute(ClientCommandId id, string arguments) =>
|
||||
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FriendsCommands_ReadAuthoritativeStateAndSendObjectId()
|
||||
{
|
||||
var friends = new FriendsState();
|
||||
friends.Apply(new FriendsUpdate(
|
||||
FriendsUpdateType.Full,
|
||||
[
|
||||
new FriendEntry(0x50000001u, "Alice", true, false, [], []),
|
||||
new FriendEntry(0x50000002u, "Bjørn", false, false, [], []),
|
||||
]));
|
||||
var calls = new List<string>();
|
||||
var messages = new List<string>();
|
||||
var controller = NewController(calls, messages: messages, friends: friends);
|
||||
|
||||
Execute(ClientCommandId.Friends, "online");
|
||||
Execute(ClientCommandId.FriendsRemove, "alice");
|
||||
Execute(ClientCommandId.FriendsAdd, "Cara");
|
||||
Execute(ClientCommandId.Friends, "old");
|
||||
|
||||
Assert.Contains("Alice (Online)", messages.Single());
|
||||
Assert.DoesNotContain("Bjørn", messages.Single());
|
||||
Assert.Equal(
|
||||
["friendremove:1342177281", "friendadd:Cara", "friendsold"],
|
||||
calls);
|
||||
|
||||
void Execute(ClientCommandId id, string arguments) =>
|
||||
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SquelchAndFilterCommands_ParseRetailOptions()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls, lastTeller: "Recent Teller");
|
||||
|
||||
Execute(ClientCommandId.Squelch, "-account Alice");
|
||||
Execute(ClientCommandId.Unsquelch, "-reply");
|
||||
Execute(ClientCommandId.Squelch, "-Magic Caster Name");
|
||||
Execute(ClientCommandId.Filter, "-Combat_Self");
|
||||
Execute(ClientCommandId.Unfilter, "-Tell");
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"accountsquelch:True:Alice",
|
||||
"charsquelch:False:0:Recent Teller:1",
|
||||
"charsquelch:True:0:Caster Name:7",
|
||||
"globalsquelch:True:22",
|
||||
"globalsquelch:False:3",
|
||||
],
|
||||
calls);
|
||||
|
||||
void Execute(ClientCommandId id, string arguments) =>
|
||||
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillComponents_ClearWorksWithoutVendorAndBuyingRequiresOne()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var messages = new List<string>();
|
||||
var controller = NewController(calls, messages: messages, vendorOpen: false);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.FillComponents, "clear"));
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.FillComponents, "scarabs 500"));
|
||||
|
||||
Assert.Equal(["clearcomps"], calls);
|
||||
Assert.Equal(["Component list cleared.", "You need an open vendor."], messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownCommandId_FailsAtApplicationBoundary()
|
||||
{
|
||||
var controller = new ClientCommandController(() => { });
|
||||
var controller = NewController();
|
||||
var command = new ExecuteClientCommandCmd((ClientCommandId)999, string.Empty);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => controller.Execute(command));
|
||||
}
|
||||
|
||||
private static ClientCommandController NewController(
|
||||
List<string>? calls = null,
|
||||
List<uint>? errors = null,
|
||||
uint? playerBitfield = 0x02000028u,
|
||||
List<string>? messages = null,
|
||||
bool isAway = false,
|
||||
bool acceptsLootPermits = true,
|
||||
FriendsState? friends = null,
|
||||
SquelchState? squelch = null,
|
||||
string? lastTeller = null,
|
||||
bool vendorOpen = false)
|
||||
{
|
||||
calls ??= [];
|
||||
errors ??= [];
|
||||
messages ??= [];
|
||||
return new ClientCommandController(new ClientCommandController.Bindings(
|
||||
() => calls.Add("ls"),
|
||||
() => calls.Add("mp"),
|
||||
() => calls.Add("pka"),
|
||||
() => calls.Add("pla"),
|
||||
() => calls.Add("house"),
|
||||
() => calls.Add("mansion"),
|
||||
() => calls.Add("age"),
|
||||
() => calls.Add("birth"),
|
||||
() => calls.Add("fps"),
|
||||
() => calls.Add("lock"),
|
||||
messages.Add,
|
||||
errors.Add,
|
||||
() => playerBitfield,
|
||||
() => "1.2.3",
|
||||
() => new Position(
|
||||
0xA9B40001u,
|
||||
new CellFrame(new System.Numerics.Vector3(1f, 2f, 3f),
|
||||
System.Numerics.Quaternion.Identity)),
|
||||
() => null,
|
||||
(message, completed) =>
|
||||
{
|
||||
calls.Add($"confirm:{message}");
|
||||
completed(true);
|
||||
},
|
||||
() => calls.Add("suicide"),
|
||||
all => calls.Add("clear:" + all),
|
||||
name => calls.Add("saveui:" + name),
|
||||
name => calls.Add("loadui:" + name),
|
||||
() => calls.Add("saveautoui"),
|
||||
() => calls.Add("loadautoui"),
|
||||
() => isAway,
|
||||
away => calls.Add("afk:" + away),
|
||||
message => calls.Add("afkmsg:" + message),
|
||||
() => acceptsLootPermits,
|
||||
enabled => calls.Add("consentmode:" + enabled),
|
||||
() => calls.Add("consentwho"),
|
||||
() => calls.Add("consentclear"),
|
||||
name => calls.Add("consentremove:" + name),
|
||||
message => calls.Add("emote:" + message),
|
||||
friends ?? new FriendsState(),
|
||||
name => calls.Add("friendadd:" + name),
|
||||
id => calls.Add("friendremove:" + id),
|
||||
() => calls.Add("friendsclear"),
|
||||
() => calls.Add("friendsold"),
|
||||
squelch ?? new SquelchState(),
|
||||
(add, id, name, type) => calls.Add($"charsquelch:{add}:{id}:{name}:{type}"),
|
||||
(add, name) => calls.Add($"accountsquelch:{add}:{name}"),
|
||||
(add, type) => calls.Add($"globalsquelch:{add}:{type}"),
|
||||
() => lastTeller,
|
||||
() => calls.Add("clearcomps"),
|
||||
() => vendorOpen,
|
||||
(category, price) => calls.Add($"fillcomps:{category}:{price}")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,12 @@ public static class FixtureLoader
|
|||
public static ElementInfo LoadPowerbarInfos()
|
||||
=> LoadInfos("powerbar_21000072.json");
|
||||
|
||||
public static ImportedLayout LoadConfirmationDialog()
|
||||
=> LayoutImporter.Build(LoadConfirmationDialogInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static ElementInfo LoadConfirmationDialogInfos()
|
||||
=> LoadInfos("dialogs_2100003C.json");
|
||||
|
||||
// ── Shared loader ────────────────────────────────────────────────────────
|
||||
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class RetailConfirmationDialogServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void FixtureBuildsProductionConfirmationWidgets()
|
||||
{
|
||||
var layout = FixtureLoader.LoadConfirmationDialog();
|
||||
|
||||
Assert.IsType<UiDialogRoot>(layout.Root);
|
||||
Assert.IsType<UiText>(layout.FindElement(RetailConfirmationDialogService.MessageElementId));
|
||||
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogService.AcceptButtonId));
|
||||
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogService.RejectButtonId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowCentersModalAndAcceptCompletesIt()
|
||||
{
|
||||
var root = new UiRoot { Width = 1024f, Height = 768f };
|
||||
var layout = FixtureLoader.LoadConfirmationDialog();
|
||||
var service = new RetailConfirmationDialogService(root, layout);
|
||||
bool? result = null;
|
||||
|
||||
service.Show("Do you really want to kill your character?", accepted => result = accepted);
|
||||
|
||||
Assert.Same(layout.Root, root.Modal);
|
||||
var popup = Assert.IsAssignableFrom<UiElement>(
|
||||
layout.FindElement(RetailConfirmationDialogService.PopupElementId));
|
||||
Assert.Equal(MathF.Round((1024f - popup.Width) * 0.5f), popup.Left);
|
||||
Assert.Equal(MathF.Round((768f - popup.Height) * 0.5f), popup.Top);
|
||||
|
||||
var accept = Assert.IsType<UiButton>(
|
||||
layout.FindElement(RetailConfirmationDialogService.AcceptButtonId));
|
||||
accept.OnClick!();
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Null(root.Modal);
|
||||
Assert.False(service.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestsArePresentedInFifoOrder()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var layout = FixtureLoader.LoadConfirmationDialog();
|
||||
var service = new RetailConfirmationDialogService(root, layout);
|
||||
var results = new List<string>();
|
||||
|
||||
service.Show("first", accepted => results.Add($"first:{accepted}"));
|
||||
service.Show("second", accepted => results.Add($"second:{accepted}"));
|
||||
|
||||
Assert.Equal(1, service.PendingCount);
|
||||
Assert.IsType<UiButton>(layout.FindElement(
|
||||
RetailConfirmationDialogService.RejectButtonId)).OnClick!();
|
||||
Assert.Equal(["first:False"], results);
|
||||
Assert.True(service.IsOpen);
|
||||
Assert.Equal(0, service.PendingCount);
|
||||
|
||||
Assert.IsType<UiButton>(layout.FindElement(
|
||||
RetailConfirmationDialogService.AcceptButtonId)).OnClick!();
|
||||
Assert.Equal(["first:False", "second:True"], results);
|
||||
Assert.False(service.IsOpen);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using System.Text.Json;
|
|||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using EnumIDMap = DatReaderWriter.DBObjs.EnumIDMap;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
|
|
@ -44,6 +45,25 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
"Asheron's Call");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
|
||||
Assert.True(dats.Portal.TryGet<EnumIDMap>(masterDid, out var master));
|
||||
Assert.True(master!.ClientEnumToID.TryGetValue(5u, out uint uiMapDid));
|
||||
Assert.True(dats.Portal.TryGet<EnumIDMap>(uiMapDid, out var uiMap));
|
||||
Assert.True(uiMap!.ClientEnumToID.TryGetValue(2u, out uint dialogsLayoutDid));
|
||||
var dialogs = LayoutImporter.ImportInfos(
|
||||
dats,
|
||||
dialogsLayoutDid,
|
||||
RetailConfirmationDialogService.RootElementId);
|
||||
Assert.NotNull(dialogs);
|
||||
var dialogsJson = JsonSerializer.Serialize(dialogs, new JsonSerializerOptions
|
||||
{
|
||||
IncludeFields = true,
|
||||
WriteIndented = true,
|
||||
});
|
||||
File.WriteAllText(
|
||||
Path.Combine(FixtureDirectory(), $"dialogs_{dialogsLayoutDid:X8}.json"),
|
||||
dialogsJson);
|
||||
|
||||
foreach (var (layoutId, fileName) in Layouts)
|
||||
{
|
||||
var info = LayoutImporter.ImportInfos(dats, layoutId);
|
||||
|
|
@ -67,6 +87,7 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
});
|
||||
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), json);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static string FixtureDirectory([CallerFilePath] string thisFile = "")
|
||||
|
|
|
|||
2942
tests/AcDream.App.Tests/UI/Layout/fixtures/dialogs_2100003C.json
Normal file
2942
tests/AcDream.App.Tests/UI/Layout/fixtures/dialogs_2100003C.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -99,6 +99,31 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
|
|||
Assert.False(saved.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamedProfile_SaveAndRestore_AppliesEveryMountedWindow()
|
||||
{
|
||||
var store = new SettingsStore(PathName);
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
RetailWindowHandle chat = Mount(root, "chat");
|
||||
RetailWindowHandle radar = Mount(root, "radar");
|
||||
using var persistence = new RetailWindowLayoutPersistence(
|
||||
root.WindowManager, store, () => "Alice", () => (800, 600));
|
||||
|
||||
chat.MoveTo(44f, 55f);
|
||||
radar.MoveTo(300f, 120f);
|
||||
radar.Hide();
|
||||
persistence.SaveNamed("hunting");
|
||||
|
||||
chat.MoveTo(1f, 2f);
|
||||
radar.MoveTo(3f, 4f);
|
||||
radar.Show();
|
||||
persistence.RestoreNamed("hunting");
|
||||
|
||||
Assert.Equal((44f, 55f), (chat.Left, chat.Top));
|
||||
Assert.Equal((300f, 120f), (radar.Left, radar.Top));
|
||||
Assert.False(radar.IsVisible);
|
||||
}
|
||||
|
||||
private static RetailWindowHandle Mount(
|
||||
UiRoot root,
|
||||
string name,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@ namespace AcDream.App.Tests.UI;
|
|||
|
||||
public class UiTextTests
|
||||
{
|
||||
[Fact]
|
||||
public void WrapWords_UsesMeasuredWidthAndPreservesParagraphs()
|
||||
{
|
||||
IReadOnlyList<string> lines = UiText.WrapWords(
|
||||
"one two three\nfour",
|
||||
text => text.Length * 10f,
|
||||
maximumWidth: 75f);
|
||||
|
||||
Assert.Equal(["one two", "three", "four"], lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampScroll_PinsToZero_WhenContentFitsView()
|
||||
{
|
||||
|
|
@ -142,6 +153,28 @@ public class UiTextTests
|
|||
Assert.Equal(new UiText.Pos(1, 8), e2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrapWords_PreservesNewlinesAndWrapsAtSpaces()
|
||||
{
|
||||
IReadOnlyList<string> lines = UiText.WrapWords(
|
||||
"one two three\nfour",
|
||||
static text => text.Length,
|
||||
maximumWidth: 7f);
|
||||
|
||||
Assert.Equal(["one two", "three", "four"], lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrapWords_SplitsAnOverWidthWordAtCharacterBoundaries()
|
||||
{
|
||||
IReadOnlyList<string> lines = UiText.WrapWords(
|
||||
"go abcdef",
|
||||
static text => text.Length,
|
||||
maximumWidth: 5f);
|
||||
|
||||
Assert.Equal(["go ab", "cdef"], lines);
|
||||
}
|
||||
|
||||
// ── VOffset: vertical positioning for single-line mode ───────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue