ModernUO/Projects/Server.Tests/Tests/PropertyList/OplTextBlockTests.cs
Kamron Batman d7668df5ee
feat(opl): OplTextBlock multi-line tooltip builder + AddChunked (#2507)
## At a glance

```csharp
public override void GetProperties(IPropertyList list)
{
    base.GetProperties(list);

    // Accumulate any number of free-text lines. On dispose the block flushes via
    // AddChunked, splitting across as many OPL properties as needed so none can
    // overflow the legacy 2D client's per-property buffer (which would crash it).
    using var block = list.TextBlock();

    if (luck > 0)
    {
        block.Add($"Luck Bonus: +{luck}%");       // zero-alloc interpolation
    }

    block.Add("Cannot be repaired".AsSpan());      // plain text, no string allocation

    // Already holding a '\n'-joined string? Skip the builder and chunk directly:
    // list.AddChunked(description);
}
```

## What

Adds a safe path for emitting **variable-length, free-form (non-cliloc) tooltip text**:

- **`ObjectPropertyList.Add(ReadOnlySpan<char>)`** overloads — append raw text with no string allocation. Makes the old single-arg `Add(string)` redundant (a `string` binds to the span overload implicitly), so it's dropped.
- **`AddChunked(ReadOnlySpan<char>)`** on `IPropertyList` — splits newline-joined text at `\n` boundaries across as many passthrough-cliloc properties as needed, so no single property exceeds the cap.
- **`OplTextBlock`** (`ref struct`) + **`IPropertyList.TextBlock()`** — an ergonomic builder that accumulates `\n`-joined lines (with a zero-alloc interpolated `Add($"...")` overload) and flushes via `AddChunked` on dispose. Usage: `using var block = list.TextBlock();`.
- **`MaxArgumentLength` (504)** — per-property cap with a hard backstop that clamps + logs anything that slips through.

## Why

The legacy 2D client copies each OPL property's text into a fixed ~512-char (1024-byte) buffer. A single property longer than that smashes an adjacent world object's vtable on the client heap and crashes the client. `AddChunked`/`OplTextBlock` keep multi-line content safely under the cap instead of risking one oversized `Add`.

## Docs

- `dev-docs/property-lists.md` — new "Multi-Line Free Text" deep-dive section; corrected the stale `IPropertyList` listing.
- `dev-docs/claude-skills/modernuo-property-lists.md` — condensed pattern + anti-pattern.

## Tests

9 tests pass (`OplTextBlockTests`, `ObjectPropertyListSpanAddTests`): line joining, empty-line skipping, no-line no-op, zero-alloc interpolation, and long-content chunking staying under `MaxArgumentLength`. Full `UOContent` build is green, confirming dropping `Add(string)` breaks no call sites.
2026-07-02 19:41:36 -07:00

108 lines
2.7 KiB
C#

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Server;
using Xunit;
namespace Server.Tests;
public class OplTextBlockTests
{
private static (int cliloc, string arg)[] Decode(ObjectPropertyList opl)
{
opl.Terminate();
var buffer = opl.Buffer;
var entries = new List<(int, string)>();
var pos = 15;
while (true)
{
var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos));
pos += 4;
if (cliloc == 0)
{
break;
}
var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos));
pos += 2;
entries.Add((cliloc, Encoding.Unicode.GetString(buffer, pos, byteLen)));
pos += byteLen;
}
return entries.ToArray();
}
[Fact]
public void MultipleLines_JoinIntoSingleCyclingEntry()
{
var opl = new ObjectPropertyList(null);
using (var block = opl.TextBlock())
{
block.Add("Line One".AsSpan());
block.Add("Line Two".AsSpan());
}
var entries = Decode(opl);
Assert.Single(entries);
Assert.Equal((1042971, "Line One\nLine Two"), entries[0]); // first cycling cliloc
}
[Fact]
public void EmptyLines_AreSkipped()
{
var opl = new ObjectPropertyList(null);
using (var block = opl.TextBlock())
{
block.Add("Only".AsSpan());
block.Add(ReadOnlySpan<char>.Empty);
}
Assert.Equal((1042971, "Only"), Decode(opl)[0]);
}
[Fact]
public void NoLines_EmitsNothing()
{
var opl = new ObjectPropertyList(null);
using (var block = opl.TextBlock())
{
// add nothing
}
Assert.Empty(Decode(opl));
}
[Fact]
public void Interpolated_AppendsZeroAlloc()
{
var opl = new ObjectPropertyList(null);
var v = 5;
using (var block = opl.TextBlock())
{
block.Add($"Luck Bonus: +{v}%");
}
Assert.Equal((1042971, "Luck Bonus: +5%"), Decode(opl)[0]);
}
[Fact]
public void LongContent_SplitsIntoMultipleEntriesUnderCap()
{
var opl = new ObjectPropertyList(null);
using (var block = opl.TextBlock())
{
for (var i = 0; i < 10; i++)
{
block.Add(new string((char)('a' + i), 100).AsSpan());
}
}
var entries = Decode(opl);
Assert.True(entries.Length > 1, "expected the long block to chunk into multiple entries");
foreach (var (_, arg) in entries)
{
Assert.True(arg.Length <= ObjectPropertyList.MaxArgumentLength);
}
}
}