using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout;
///
/// Campaign OP slice OP4 (2026-08-11) conformance + behavior tests for
/// — the CH4 registry-
/// conformance pattern: every one of the 50 authored rows pinned against
/// in BOTH directions (an invented row
/// or a dropped row fails the build), the authored group/order pinned
/// against the committed fixture, and the row-building/Apply-Reset-
/// Defaults/wire-publish behavior exercised end-to-end against the
/// committed options_panel_2100006E_1000018D.json fixture — no
/// live DAT access, following the same hermetic pattern
/// OptionsPanelControllerTests/OptionsPanelLayoutConformanceTests
/// already established.
///
public sealed class CharacterOptionsPageControllerTests
{
// The three PlayerOption ids that exist but carry NO Character-tab row
// (research doc §2.7): AppearOffline, UseMouseTurning, LockUI.
private static readonly CharacterOptionId[] NotOnCharacterTab =
[
CharacterOptionId.AppearOffline,
CharacterOptionId.UseMouseTurning,
CharacterOptionId.LockUI,
];
private static IEnumerable AllRows() =>
CharacterOptionsPageController.Groups.SelectMany(static g => g.Rows);
// ── Pure data conformance (no fixtures, no widgets) ─────────────────────
[Fact]
public void Groups_HasSixGroups_InAuthoredHeaderOrder()
{
string[] expectedHeaders =
[
"ID_CharacterOption_UIBehavior_Section",
"ID_CharacterOption_UIDisplay_Section",
"ID_CharacterOption_Grouping_Section",
"ID_CharacterOption_OtherPlayers_Section",
"ID_CharacterOption_CharacterBehavior_Section",
"ID_CharacterOption_Chat_Section",
];
Assert.Equal(6, CharacterOptionsPageController.Groups.Length);
Assert.Equal(
expectedHeaders,
CharacterOptionsPageController.Groups.Select(static g => g.HeaderKey));
}
[Fact]
public void Groups_RowCountsPerGroup_Match3_15_6_11_7_8()
{
// research doc §2 / §7: 3/15/6/11/7/7, +1 for D3's HearPKDeaths
// appended to the Chat group -> 8.
int[] expected = { 3, 15, 6, 11, 7, 8 };
Assert.Equal(
expected,
CharacterOptionsPageController.Groups.Select(static g => g.Rows.Length));
}
[Fact]
public void TotalRowCount_Is50()
{
Assert.Equal(50, CharacterOptionsPageController.TotalRowCount);
Assert.Equal(50, AllRows().Count());
}
[Fact]
public void EveryRow_ResolvesInCharacterOptionTable()
{
// "An invented row fails the build" — direction 1.
foreach (CharacterOptionsPageController.RowSpec row in AllRows())
{
Assert.True(
CharacterOptionTable.TryGet(row.Id, out _),
$"{row.RetailName} (0x{(uint)row.Id:X2}) is authored on the Character tab "
+ "but missing from CharacterOptionTable.");
}
}
[Fact]
public void EveryRow_IsPairwiseDistinct()
{
var ids = AllRows().Select(static r => r.Id).ToList();
Assert.Equal(ids.Count, ids.Distinct().Count());
}
[Fact]
public void EveryCharacterOptionTableId_ExceptTheThreeExcluded_HasExactlyOneRow()
{
// "A dropped row fails the build" — direction 2. CharacterOptionTable
// has 53 ids; the Character tab authors exactly 53 - 3 = 50 of them
// (research doc §2.7's three exclusions).
var rowIds = AllRows().Select(static r => r.Id).ToHashSet();
foreach (CharacterOptionTableEntry entry in CharacterOptionTable.All)
{
bool expectedOnTab = !NotOnCharacterTab.Contains(entry.Id);
Assert.True(
rowIds.Contains(entry.Id) == expectedOnTab,
$"{entry.Id} (0x{(uint)entry.Id:X2}): expected authored-on-tab="
+ $"{expectedOnTab} but rowIds.Contains={rowIds.Contains(entry.Id)}.");
}
}
[Theory]
[InlineData(CharacterOptionId.AppearOffline)]
[InlineData(CharacterOptionId.UseMouseTurning)]
[InlineData(CharacterOptionId.LockUI)]
public void ExcludedIds_HaveNoRow(CharacterOptionId excludedId)
{
Assert.DoesNotContain(AllRows(), r => r.Id == excludedId);
}
[Fact]
public void HearPkDeathMessages_D3Row_IsLastInTheChatGroup()
{
CharacterOptionsPageController.RowSpec[] chatRows =
CharacterOptionsPageController.Groups[5].Rows;
Assert.Equal(
CharacterOptionId.HearPkDeathMessages, chatRows[^1].Id);
Assert.Equal("HearPKDeaths", chatRows[^1].RetailName);
}
[Fact]
public void HearPkDeathMessages_RetailNameHash_MatchesByteVerifiedStringId()
{
// structure doc §7 build-version divergence note:
// compute_str_hash("ID_PlayerOption_HearPKDeaths") == 0x0D16E9A3
// exactly, matching the DAT string "Listen to PK death messages."
Assert.Equal(
0x0D16E9A3u,
DatStringResolver.ComputeHash("ID_PlayerOption_HearPKDeaths"));
}
[Theory]
[InlineData("ID_CharacterOption_UIBehavior_Section", 0x06489B6Eu)]
[InlineData("ID_CharacterOption_UIDisplay_Section", 0x0A9BC99Eu)]
[InlineData("ID_CharacterOption_Grouping_Section", 0x0CBAAFAEu)]
[InlineData("ID_CharacterOption_OtherPlayers_Section", 0x0872DFFEu)]
[InlineData("ID_CharacterOption_CharacterBehavior_Section", 0x08674D5Eu)]
[InlineData("ID_CharacterOption_Chat_Section", 0x0987FE8Eu)]
public void HeaderKey_RetailNameHash_MatchesByteVerifiedStringId(
string headerKey, uint expectedHash)
{
// SF-2 (OP4 review-fix round, 2026-08-11): structure doc §7's six
// byte-verified header string ids — previously verified only by
// hand in the mechanism review, not pinned by a test. A typo in a
// HeaderKey literal would otherwise produce a silently blank
// header that only the user's eye catches.
Assert.Equal(expectedHash, DatStringResolver.ComputeHash(headerKey));
Assert.Contains(headerKey, CharacterOptionsPageController.Groups.Select(g => g.HeaderKey));
}
[Theory]
[MemberData(nameof(RetailEnumNameCases))]
public void RetailName_MatchesVerbatimAcclientEnumSpelling(
CharacterOptionId id, string expectedRetailName)
{
// acclient.h:4162-4218's OWN PlayerOption enumerator names — the six
// Hear*Chat ids (+ D3's HearPKDeaths) differ from acdream's own
// ListenTo*Chat CharacterOptionId spelling; every other id matches
// 1:1. Only a representative spot-check here — the FULL authored
// list is pinned row-by-row in AuthoredOrder_MatchesResearchDocRowByRow.
CharacterOptionsPageController.RowSpec row =
AllRows().Single(r => r.Id == id);
Assert.Equal(expectedRetailName, row.RetailName);
}
public static IEnumerable