fix(ui,runtime): OP4 re-review residuals R1-R4 (coordinator pass) — OP4 CLOSED
R1: the timestamp prefix moves from ChatLog.Append (which stamped the
stored BODY, rendering 'Alice says, "13:05:09 hi"') to ChatVM's display
composition — FormatTimestampPrefix(entry.Received) prepends the COMPOSED
line, matching retail's separate-leading-string model (fprintf("%ls%ls",
ts, text) @0x00563e5b; AddTextToScroll receives composed lines). The
prefix renders entry.Received in LOCAL time (retail strftime), invariant
literal colons. The ten defect-pinning test cases across
ChatLogTests/RuntimeCommunicationStateTests are rewritten to pin the
corrected contract (stored bodies stay clean; the composed line carries
the stamp outside the quotes — ChatVMTests).
R2: open option-bearing panels converge on every PlayerDescription seed:
OptionPage.ReloadFromLive (per-row live re-read + gating re-eval, NO
AfterApply flush — the seed just cleared the dirty module),
OptionsPanelController.OnServerOptionsSeeded (active page),
CombatUiController.OnServerOptionsSeeded (SyncControls), wired through
RuntimeSettingsController.ServerOptionsSeeded from the same factory hook
LockUI already uses. Retail cannot reach this state (its panels close
across login); the adaptation exists because retained panels survive the
session boundary — documented at the seam.
R3: tests drive the refresh widget push (model AND checkbox converge) and
ReloadFromLive's no-flush contract. R4: AP-196 addendum names the
headless AutoRepeatAttack false->true effective-default flip and the
characterOptions escape hatch.
Full Release suite: 13,083 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e71e5a9614
commit
ac0304dcf0
13 changed files with 216 additions and 114 deletions
|
|
@ -372,4 +372,63 @@ public sealed class OptionPageModelTests
|
|||
|
||||
Assert.Equal(1, notifyCount);
|
||||
}
|
||||
|
||||
// ── OP4 re-review R3: SaveCurrentValue's live re-read pushes the WIDGET
|
||||
// too (the refresh delegate), so a re-seed converges both the row's
|
||||
// model state AND the visible checkbox. ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SaveCurrentValue_ReReadsLiveSource_AndPushesTheWidgetRefresh()
|
||||
{
|
||||
bool live = false;
|
||||
bool widgetChecked = true; // deliberately out of sync with live
|
||||
var row = new BoolOptionRow(
|
||||
initial: true,
|
||||
defaultValue: false,
|
||||
read: () => live,
|
||||
refresh: value => widgetChecked = value);
|
||||
|
||||
live = false;
|
||||
row.SaveCurrentValue();
|
||||
|
||||
// Model AND widget both converge on the live source.
|
||||
Assert.False(row.Current);
|
||||
Assert.False(row.Changed);
|
||||
Assert.False(widgetChecked);
|
||||
|
||||
live = true;
|
||||
row.SaveCurrentValue();
|
||||
Assert.True(row.Current);
|
||||
Assert.True(widgetChecked);
|
||||
}
|
||||
|
||||
// ── OP4 re-review R2: ReloadFromLive = per-row live re-read + gating
|
||||
// re-eval, WITHOUT Apply's AfterApply flush (a seed just cleared the
|
||||
// dirty module — a flush publication here would be spurious). ────────
|
||||
|
||||
[Fact]
|
||||
public void ReloadFromLive_ReReadsRows_WithoutFiringAfterApply()
|
||||
{
|
||||
bool live = false;
|
||||
bool widgetChecked = false;
|
||||
int flushCount = 0;
|
||||
int gatingCount = 0;
|
||||
var page = new OptionPage { AfterApply = () => flushCount++ };
|
||||
var row = new BoolOptionRow(
|
||||
initial: false,
|
||||
defaultValue: false,
|
||||
read: () => live,
|
||||
refresh: value => widgetChecked = value);
|
||||
page.Register(row);
|
||||
page.OnOptionChanged = () => gatingCount++;
|
||||
|
||||
live = true;
|
||||
page.ReloadFromLive();
|
||||
|
||||
Assert.True(row.Current);
|
||||
Assert.True(widgetChecked);
|
||||
Assert.False(row.Changed); // (current, saved) both re-read — no phantom dirt
|
||||
Assert.Equal(0, flushCount); // NO AfterApply publication on a seed
|
||||
Assert.Equal(1, gatingCount); // Apply/Reset ghosting re-evaluated
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -339,78 +339,39 @@ public sealed class ChatLogTests
|
|||
Assert.Equal(0x06u, log.Snapshot()[0].LogTextType);
|
||||
}
|
||||
|
||||
// ── SF-1/S1 (OP4 review-fix round, 2026-08-11): DisplayTimestampsSource
|
||||
// prefixes EVERY producer through the shared Append seam, not just
|
||||
// RuntimeCommunicationState.AddText's own subset of callers. ────────
|
||||
|
||||
[Fact]
|
||||
public void DisplayTimestampsSource_Unbound_NoPrefix()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
log.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
|
||||
Assert.Equal("hi", log.Snapshot()[0].Text);
|
||||
}
|
||||
// ── OP4 re-review R1 (2026-08-11): the timestamp NEVER touches the stored
|
||||
// body — retail composes the display line first and prepends the stamp
|
||||
// as a separate leading string at display time (fprintf("%ls%ls", ts,
|
||||
// text) @0x00563e5b). ChatVM's display composition owns the prefix;
|
||||
// these tests pin that the LOG stays clean and the format is invariant.
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void DisplayTimestampsSource_GatesThePrefix(bool timestampsOn)
|
||||
public void StoredEntryText_NeverCarriesTheTimestampPrefix(bool timestampsOn)
|
||||
{
|
||||
var log = new ChatLog { DisplayTimestampsSource = () => timestampsOn };
|
||||
log.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
|
||||
|
||||
string text = log.Snapshot()[0].Text;
|
||||
if (timestampsOn)
|
||||
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} hi$", text);
|
||||
else
|
||||
Assert.Equal("hi", text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Every public ingestion method — proving the prefix applies at the
|
||||
// ONE shared Append seam, not per-caller.
|
||||
[InlineData("OnEmote")]
|
||||
[InlineData("OnSoulEmote")]
|
||||
[InlineData("OnChannelBroadcast")]
|
||||
[InlineData("OnTellReceived")]
|
||||
[InlineData("OnSystemMessage")]
|
||||
[InlineData("OnPopup")]
|
||||
[InlineData("OnCombatLine")]
|
||||
[InlineData("OnSelfSent")]
|
||||
[InlineData("OnPlayerKilled")]
|
||||
public void DisplayTimestampsSource_AppliesToEveryProducer(string method)
|
||||
{
|
||||
var log = new ChatLog { DisplayTimestampsSource = () => true };
|
||||
|
||||
switch (method)
|
||||
{
|
||||
case "OnEmote": log.OnEmote("Caith", "waves", 0xCAFEu); break;
|
||||
case "OnSoulEmote": log.OnSoulEmote("Bob", "dances", 0xBEEFu); break;
|
||||
case "OnChannelBroadcast": log.OnChannelBroadcast(42u, "Alice", "motd"); break;
|
||||
case "OnTellReceived": log.OnTellReceived("Alice", "psst", 0xAAu, logTextType: 0x03u); break;
|
||||
case "OnSystemMessage": log.OnSystemMessage("fizzled", chatType: 5); break;
|
||||
case "OnPopup": log.OnPopup("modal"); break;
|
||||
case "OnCombatLine": log.OnCombatLine("hit", logTextType: 0x06u); break;
|
||||
case "OnSelfSent": log.OnSelfSent(ChatKind.Tell, "hey", logTextType: 0x04u, targetOrChannel: "Alice"); break;
|
||||
case "OnPlayerKilled": log.OnPlayerKilled("died", 0x1u, 0x2u); break;
|
||||
}
|
||||
|
||||
string text = log.Snapshot()[0].Text;
|
||||
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} .+$", text);
|
||||
// The stored body is ALWAYS the raw message — the option gates the
|
||||
// display composition (ChatVM), never the log content.
|
||||
Assert.Equal("hi", log.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayTimestampsSource_UsesLiteralColons_RegardlessOfCurrentCulture()
|
||||
public void FormatTimestampPrefix_UsesLiteralColons_RegardlessOfCurrentCulture()
|
||||
{
|
||||
CultureInfo original = Thread.CurrentThread.CurrentCulture;
|
||||
try
|
||||
{
|
||||
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fi-FI");
|
||||
var log = new ChatLog { DisplayTimestampsSource = () => true };
|
||||
|
||||
log.OnSystemMessage("fizzled", chatType: 0);
|
||||
string prefix = ChatLog.FormatTimestampPrefix(
|
||||
new DateTime(2026, 8, 11, 13, 5, 9, DateTimeKind.Utc));
|
||||
|
||||
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} fizzled$", log.Snapshot()[0].Text);
|
||||
// fi-FI's time separator is '.', so a culture-dependent format
|
||||
// would emit "16.05.09 " here; retail's strftime is a literal ':'.
|
||||
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} $", prefix);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
|
|
@ -267,65 +267,36 @@ public sealed class RuntimeCommunicationStateTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void AddText_TimestampsTrue_PrefixesTranscriptLine()
|
||||
public void AddText_TimestampsTrue_StoredBodyStaysClean()
|
||||
{
|
||||
// OP4 re-review R1 (2026-08-11): the prefix belongs to the DISPLAY
|
||||
// composition (ChatVM prepends FormatTimestampPrefix(entry.Received)
|
||||
// to the COMPOSED line — retail fprintf("%ls%ls", ts, text)
|
||||
// @0x00563e5b), never to the stored body. The earlier fix round
|
||||
// prefixed entry.Text here, which put the stamp INSIDE the quotes of
|
||||
// composed kinds ('Alice says, "13:05:09 hi"').
|
||||
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
|
||||
|
||||
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
|
||||
|
||||
string text = state.Chat.Snapshot()[0].Text;
|
||||
Assert.EndsWith("Your spell fizzled.", text);
|
||||
Assert.NotEqual("Your spell fizzled.", text);
|
||||
// Retail ctor default format "%#H:%M:%S " (non-zero-padded 24h
|
||||
// hour, zero-padded minute:second, trailing space before the
|
||||
// text) — .NET "H\:mm\:ss " (colons ESCAPED, not the
|
||||
// culture-dependent TimeSeparator placeholder) is the exact
|
||||
// equivalent (SF-1/S4, OP4 review-fix round, 2026-08-11).
|
||||
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", text);
|
||||
Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddText_TimestampsTrue_UsesLiteralColons_RegardlessOfCurrentCulture()
|
||||
public void DisplayTimestampsSource_ForwardsToChat_SoTheDisplaySeamSeesOneSource()
|
||||
{
|
||||
// SF-1/S4 (OP4 review-fix round, 2026-08-11): an unescaped "H:mm:ss"
|
||||
// format string renders ':' as CurrentCulture.DateTimeFormat.
|
||||
// TimeSeparator, which is NOT ':' on cultures like fi-FI ("."). The
|
||||
// fix escapes the colons and forces InvariantCulture — prove the
|
||||
// output stays colon-separated even under a culture that would
|
||||
// otherwise substitute a different separator.
|
||||
CultureInfo original = Thread.CurrentThread.CurrentCulture;
|
||||
try
|
||||
{
|
||||
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fi-FI");
|
||||
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
|
||||
|
||||
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
|
||||
|
||||
string text = state.Chat.Snapshot()[0].Text;
|
||||
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.CurrentThread.CurrentCulture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayTimestampsSource_ForwardsToChat_TimestampingEveryProducer_NotJustAddText()
|
||||
{
|
||||
// S1 (OP4 review-fix round, 2026-08-11, blast lens): AddText's own
|
||||
// callers (ServerMessage/WeenieError) were a strict SUBSET of
|
||||
// every chat producer — heard speech, emotes, Turbine channels,
|
||||
// and combat text all bypassed it by calling ChatLog's own OnXxx
|
||||
// methods directly. Setting DisplayTimestampsSource on this class
|
||||
// must timestamp lines that never go through AddText at all.
|
||||
// R1: this class still owns the ONE forwarding seam — ChatVM reads
|
||||
// ChatLog.DisplayTimestampsSource at display composition, so setting
|
||||
// it HERE must reach the log's property (every producer's entries
|
||||
// then render prefixed, ChatVMTests pins the composed-line shape).
|
||||
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
|
||||
|
||||
state.Chat.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
|
||||
Assert.NotNull(state.Chat.DisplayTimestampsSource);
|
||||
Assert.True(state.Chat.DisplayTimestampsSource!());
|
||||
|
||||
string text = state.Chat.Snapshot()[0].Text;
|
||||
Assert.EndsWith("hi", text);
|
||||
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} hi$", text);
|
||||
// And the stored body of a non-AddText producer stays clean too.
|
||||
state.Chat.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
|
||||
Assert.Equal("hi", state.Chat.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -161,4 +161,37 @@ public sealed class ChatVMTests
|
|||
var entry = Assert.Single(log.Snapshot());
|
||||
Assert.Equal(0x00u, entry.LogTextType);
|
||||
}
|
||||
|
||||
// ── OP4 re-review R1 (2026-08-11): the timestamp prefixes the COMPOSED
|
||||
// display line, never the body — '13:05:09 Alice says, "hi"', not
|
||||
// 'Alice says, "13:05:09 hi"' (retail fprintf("%ls%ls", ts, text)
|
||||
// @0x00563e5b). ─────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void DisplayTimestamps_PrefixTheComposedLine_NotTheBody(bool timestampsOn)
|
||||
{
|
||||
var log = new ChatLog { DisplayTimestampsSource = () => timestampsOn };
|
||||
log.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
|
||||
var vm = new ChatVM(log);
|
||||
|
||||
string plain = Assert.Single(vm.RecentLines());
|
||||
var detailed = Assert.Single(vm.RecentLinesDetailed());
|
||||
|
||||
if (timestampsOn)
|
||||
{
|
||||
// The stamp leads the whole composed line, OUTSIDE the quotes.
|
||||
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Alice says, ""hi""$", plain);
|
||||
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Alice says, ""hi""$", detailed.Text);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal("Alice says, \"hi\"", plain);
|
||||
Assert.Equal("Alice says, \"hi\"", detailed.Text);
|
||||
}
|
||||
|
||||
// The stored body never carries the stamp in either state.
|
||||
Assert.Equal("hi", log.Snapshot()[0].Text);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue