fix #430 #440: character panel — tooltips can mount, and rows refresh on the authoritative record
Some checks failed
CI / linux-portable (push) Failing after 3m25s
CI / windows-gate (push) Successful in 6m12s
CI / release (push) Has been skipped

Two defects the owner found at the CA5 drive, one shared theme: the data
was right and the presentation seam was dead.

#430 (tooltips): the TS-85 Batch-B port set runtime TooltipText on the
runtime-built attribute/vital/skill rows but never gave them a popup
locator, and RetailTooltipPresenter.OnTooltipShow refuses any widget with
AuthoredTooltipRootElementId == 0 — the tooltip could never mount, on any
row, ever. (The register's 'live-verified on the Character tab' was the
OPTIONS panel's Character tab — authored elements with authored locators;
a different surface.) Rows now carry the shared popup skin
0x10000395/0x21000041 — live-DAT probed as the ONLY locator pair the
character layout references, and the same inference UiItemSlot already
ships for runtime-built widgets. TS-85's row carries the dated correction.

#440 (train row stuck): training a skill debited credits on screen but
left the row in the untrained section until the NEXT click — because the
sheet-changed subscription only refreshed the captured sheet, and row
STRUCTURE rebuilt exclusively in click handlers (the raise 'completed'
callback runs after SEND, before the server answers; the owner's second
click was simply the first rebuild after the record landed, and ACE's
rejection of that second train — 'Failed to train', no credit change —
matches the owner's report exactly). The same gap kept CA4's
awaiting-ghost from visually releasing. CharacterStatController.Bind now
returns the data-changed refresh and MountCharacter invokes it on every
authoritative sheet change, mirroring retail's quality-change broadcast
(InfoRegion::OnQualityChanged @ 0x004F0EB0).

Pinned by DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick
and Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText. Owner
visual re-check owed next session (hover-dwell a row; train a skill and
watch it move immediately). Full hermetic suite 15,329 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 16:51:58 +02:00
parent bd943849b1
commit bce17b3cfb
5 changed files with 177 additions and 7 deletions

View file

@ -883,6 +883,106 @@ public class CharacterStatControllerTests
Assert.Equal(Vector4.One, healingTexts[2].LinesProvider()[0].Color);
}
[Fact]
public void DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick()
{
// #431 CA5-gate bug: a TrainSkill's authoritative record updated the
// sheet, but rows only rebuilt on CLICKS — the skill sat in the
// untrained section (credits visibly debited) until the user clicked
// train AGAIN. Bind now returns the data-changed refresh; invoking
// it must re-bucket the rows, mirroring retail's quality-change
// broadcast (InfoRegion::OnQualityChanged @ 0x004F0EB0).
var list = new UiPanel { Width = 300 };
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterSheet sheet = SampleData.SampleCharacter();
Action refresh = CharacterStatController.Bind(layout, () => sheet,
spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
var untrained = sheet.Skills.First(
s => s.AdvancementClass == CharacterSkillAdvancementClass.Untrained);
int trainedBefore = sheet.Skills.Count(
s => s.AdvancementClass == CharacterSkillAdvancementClass.Trained);
Assert.Equal(trainedBefore, RowsUnderHeader(list, "Trained Skills"));
// The server record lands: the skill is now Trained. CharacterSheet
// is a plain class (init-only), so rebuild it like the provider does.
CharacterSheet updated = SampleData.SampleCharacter();
updated.GetType(); // (no-op guard for clarity)
sheet = new CharacterSheet
{
Name = sheet.Name,
UnassignedXp = sheet.UnassignedXp,
SkillCredits = sheet.SkillCredits,
Skills = sheet.Skills
.Select(s => s.Id == untrained.Id
? s with { AdvancementClass = CharacterSkillAdvancementClass.Trained }
: s)
.ToList(),
};
refresh();
Assert.Equal(trainedBefore + 1, RowsUnderHeader(list, "Trained Skills"));
}
[Fact]
public void Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText()
{
// #430: runtime-built rows author no popup locator, and the tooltip
// presenter refuses a widget without one — so the row descriptions
// could never mount. Rows must carry the shared popup skin (the only
// locator pair the character layout itself references; the same
// inference UiItemSlot ships) alongside their runtime text.
var list = new UiPanel { Width = 300 };
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
var attributeRows = list.Children.OfType<UiClickablePanel>().ToList();
Assert.NotEmpty(attributeRows);
Assert.All(attributeRows, row =>
{
Assert.Equal(
RetailTooltipPresenter.SharedPopupSkinRootElementId,
row.AuthoredTooltipRootElementId);
Assert.Equal(
RetailTooltipPresenter.SharedPopupSkinLayoutDid,
row.AuthoredTooltipLayoutDid);
});
// The six attribute rows carry the retail descriptions.
Assert.All(
attributeRows.Take(6),
row => Assert.False(string.IsNullOrEmpty(row.GetTooltipText())));
}
private static int RowsUnderHeader(UiElement list, string header)
{
// The rows live inside a nested content container; find the
// container whose ordered children interleave section headers and
// rows, then count rows between the named header and the next one.
static bool IsHeader(UiElement c, string text) =>
c is UiPanel and not UiClickablePanel
&& c.Children.OfType<UiText>().Any(
x => x.LinesProvider()[0].Text == text);
UiElement? container = new[] { list }.Concat(Descendants(list))
.FirstOrDefault(e => e.Children.Any(c => IsHeader(c, header)));
Assert.NotNull(container);
var children = container!.Children.ToList();
int start = children.FindIndex(c => IsHeader(c, header));
Assert.True(start >= 0, $"header '{header}' not found");
int count = 0;
for (int i = start + 1; i < children.Count; i++)
{
if (children[i] is UiClickablePanel) count++;
else if (children[i] is UiPanel p
&& p.Children.OfType<UiText>().Any()) break;
}
return count;
}
[Fact]
public void SkillsTab_ClickThenAttributesTab_RestoresAttributeRows()
{