fix(vtank): slice 7 fix round C items F7+F12 — materialize ItemHandsColumn once

ItemHandsColumn ran a fresh .Select(...).ToArray() over _itemRows, PLUS a
per-row BaseItemName suffix-strip to recover the undecorated name, on
every single retained-UI draw. Folded per item F12: RefreshItemEditors
now captures the raw SortedCombatItemNames() result once as
_itemBaseNames — the SAME array used both to build the decorated
_itemRows (append "   [no buffs]" where it applies) and to compute
_itemHandsColumn directly, so the "   [no buffs]" suffix has exactly one
definition (added going forward) instead of two (added in RefreshItemEditors,
parsed back off in the old ItemHandsColumn getter).

CycleItemHandsAtCore (the grid's own "click cycles handedness" cell
action) mutates _itemHandedness but did not call RefreshItemEditors —
unlike the Monsters grid's mutators, which all already refresh after
mutating. Added the call so the cached column stays correct; also
switched it to read the cached _itemBaseNames instead of a second
SortedCombatItemNames() call.

Mutation named: temporarily restored the old live-recomputing
ItemHandsColumn getter (with a local copy of the suffix-stripping helper)
and confirmed the new
ItemHandsColumnDoesNotReallocateOnEveryReadAndNoBuffSuffixNeverLeaks pin
fails (Assert.Same throws — different array instances per read) before
restoring the fix. The same test also proves the no-buffs case still
resolves handedness correctly through the folded base-name array.

MossTank suite 680 -> 681 (one new pin). Full solution build green; App
markup/plugin filter 203/203.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 15:37:26 +02:00
parent 3b8d021946
commit 37055bddce
2 changed files with 63 additions and 15 deletions

View file

@ -189,6 +189,14 @@ internal sealed partial class MossTankPanel
MonsterDamageType.None,
];
private IReadOnlyList<string> _itemRows = Array.Empty<string>();
// Fix round C item F12: the same SortedCombatItemNames() pass that
// builds _itemRows (with the " [no buffs]" suffix appended where it
// applies) also stores the raw, undecorated names here — the ONE
// place that decoration is added. ItemHandsColumn used to re-derive
// the base name by stripping the suffix back off each display row
// (BaseItemName), a second, opposite-direction definition of the same
// fact that could drift from the first if the suffix ever changed.
private IReadOnlyList<string> _itemBaseNames = Array.Empty<string>();
private IReadOnlyList<string> _consumableRows = Array.Empty<string>();
private int _selectedItemRow;
private int _selectedConsumableRow;
@ -203,6 +211,14 @@ internal sealed partial class MossTankPanel
// adaptation" note) rather than inventing new profile-wide behavior.
private readonly Dictionary<string, int> _itemHandedness =
new(StringComparer.Ordinal);
// Fix round C item F7: ItemHandsColumn used to run a fresh
// .Select(...).ToArray() over _itemRows, PLUS a per-row BaseItemName
// suffix-strip, on every single retained-UI draw. Materialized once
// in RefreshItemEditors, refreshed by every real mutator that can
// change a row's handedness or the roster itself (see
// CycleItemHandsAtCore, DeleteItemRowAtCore, and every other
// RefreshItemEditors call site).
private IReadOnlyList<string> _itemHandsColumn = Array.Empty<string>();
private static readonly string[] HandednessCycle =
["Auto", "1-Handed", "2-Handed"];
private IReadOnlyList<string> _excludedComponentRows = Array.Empty<string>();
@ -688,9 +704,7 @@ internal sealed partial class MossTankPanel
// §1 "Tab: Items"; PluginCore.cs:8529-8562 — col 0 click deletes, col 1
// click cycles handedness) ──────────────────────────────────────────
public IReadOnlyList<string> ItemNameColumn => _itemRows;
public IReadOnlyList<string> ItemHandsColumn => _itemRows
.Select(row => HandednessCycle[HandednessIndex(BaseItemName(row))])
.ToArray();
public IReadOnlyList<string> ItemHandsColumn => _itemHandsColumn;
public Action<int> DeleteItemRowAt => DeleteItemRowAtCore;
public Action<int> CycleItemHandsAt => CycleItemHandsAtCore;
@ -1799,11 +1813,16 @@ internal sealed partial class MossTankPanel
private void RefreshItemEditors()
{
RefreshConsumableCategories();
_itemRows = SortedCombatItemNames()
string[] baseNames = SortedCombatItemNames();
_itemBaseNames = baseNames;
_itemRows = baseNames
.Select(name => _noBuffItemNames.Contains(name)
? name + " [no buffs]"
: name)
.ToArray();
_itemHandsColumn = baseNames
.Select(name => HandednessCycle[HandednessIndex(name)])
.ToArray();
_consumableRows = _combatSettings.ConsumableNames
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
@ -1822,14 +1841,6 @@ internal sealed partial class MossTankPanel
_excludedComponentRows.Count);
}
private static string BaseItemName(string displayRow)
{
const string suffix = " [no buffs]";
return displayRow.EndsWith(suffix, StringComparison.Ordinal)
? displayRow[..^suffix.Length]
: displayRow;
}
private int HandednessIndex(string name) =>
_itemHandedness.TryGetValue(name, out int value) ? value : 0;
@ -1849,11 +1860,15 @@ internal sealed partial class MossTankPanel
private void CycleItemHandsAtCore(int row)
{
string[] names = SortedCombatItemNames();
if ((uint)row >= (uint)names.Length)
if ((uint)row >= (uint)_itemBaseNames.Count)
return;
string name = names[row];
string name = _itemBaseNames[row];
_itemHandedness[name] = (HandednessIndex(name) + 1) % HandednessCycle.Length;
// Fix round C item F7: ItemHandsColumn is now materialized in
// RefreshItemEditors, not recomputed on every read — this real
// mutator (the grid's own "click cycles handedness" action) must
// refresh it so the next read reflects the new cycle position.
RefreshItemEditors();
}
private const string ExcludedComponentDelimiter = "; ";

View file

@ -943,6 +943,39 @@ public sealed class MossTankPanelTests
Assert.Equal(["Ice Wand"], panel.ItemNameColumn);
}
[Fact]
public void ItemHandsColumnDoesNotReallocateOnEveryReadAndNoBuffSuffixNeverLeaks()
{
// Fix round C item F7: ItemHandsColumn used to run a fresh
// .Select(...).ToArray() over _itemRows PLUS a per-row BaseItemName
// suffix-strip on every single retained-UI draw. It is now
// materialized once in RefreshItemEditors: repeated reads with no
// mutation in between must return the SAME array instance.
// Fix round C item F12: the handedness lookup now uses the raw
// base-name array captured alongside the decorated display row
// (ONE place the " [no buffs]" suffix is added, in
// RefreshItemEditors) instead of parsing the suffix back off — an
// item added with noBuffs must still resolve its own handedness
// correctly, proving the fold didn't break the no-buffs case.
var automation = new FakeAutomation
{
ItemEntries = [Item(10, "Fire Sword", 1)],
};
var host = new FakeHost(automation);
var panel = new MossTankPanel(host);
host.Selection.Select(10);
panel.AddSelectedItemNoBuffs();
Assert.Equal(["Fire Sword [no buffs]"], panel.ItemNameColumn);
Assert.Same(panel.ItemHandsColumn, panel.ItemHandsColumn);
Assert.Equal("Auto", panel.ItemHandsColumn[0]);
panel.CycleItemHandsAt(0);
IReadOnlyList<string> afterCycle = panel.ItemHandsColumn;
Assert.Equal("1-Handed", afterCycle[0]);
Assert.Same(afterCycle, panel.ItemHandsColumn);
}
[Fact]
public void ConsumablesLeftListRowClickRemovesTheRowDirectly()
{