summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop.Tests/P43HudTests.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 19:03:22 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 19:03:22 +0800
commit588560deda6b701a62653dff19079dc0a21fa944 (patch)
tree3d734aaf9ad546ec52a42af0a2c2635d2d26d670 /src/LivingVillage.Desktop.Tests/P43HudTests.fs
parent5c67e85688ff1e6150b03501adae1a5d5e67bbdc (diff)
downloadliving-village-588560deda6b701a62653dff19079dc0a21fa944.tar.gz
p43: HUD 星露谷化切片 — 右上日历钟卡/体力条形图/底部10格hotbar(1-0选中+高亮+输入隔离)
Diffstat (limited to 'src/LivingVillage.Desktop.Tests/P43HudTests.fs')
-rw-r--r--src/LivingVillage.Desktop.Tests/P43HudTests.fs129
1 files changed, 129 insertions, 0 deletions
diff --git a/src/LivingVillage.Desktop.Tests/P43HudTests.fs b/src/LivingVillage.Desktop.Tests/P43HudTests.fs
new file mode 100644
index 0000000..18f2e78
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P43HudTests.fs
@@ -0,0 +1,129 @@
+namespace LivingVillage.Desktop.Tests
+
+open System
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+open LivingVillage.Desktop
+
+/// P43 界面切片:右上日历钟卡、体力条形图、底部 10 格 hotbar 的纯模型与输入隔离回归。
+[<TestClass>]
+type P43HudTests () =
+
+ let world = Sim.initialWorldN 42UL 4
+ let tickNoon = M6Presentation.resolveStartTick (Some 12.0) false
+ let tickNight = M6Presentation.resolveStartTick (Some 23.5) false
+
+ let assertEqualMsg (expected: 'a) (actual: 'a) (message: string) =
+ Assert.IsTrue((expected = actual), sprintf "%s (expected %A, actual %A)" message expected actual)
+
+ let closeTo (expected: float32) (actual: float32) (message: string) =
+ Assert.IsTrue(abs (expected - actual) < 1e-4f, sprintf "%s (expected %f, actual %f)" message expected actual)
+
+ // ---- 1. 日历钟卡 ----
+
+ [<TestMethod>]
+ member _.CalendarReadingIsDerivedFromRealTick () =
+ let reading = HudLayout.calendarReading tickNoon
+ assertEqualMsg (M6Presentation.timePeriodLabel tickNoon) reading.Period "时段须取自 tick 纯函数"
+ assertEqualMsg (M6Presentation.clockTimeLabel tickNoon) reading.Clock "时钟须取自 tick 纯函数"
+ assertEqualMsg (tickNoon / Sim.ticksPerDay + 1L) reading.DayNumber "第 N 天 = tick/ticksPerDay + 1"
+ Assert.IsTrue(HudLayout.seasonNames |> Array.contains reading.Season, "季节须为四季之一")
+ let seasonOfDay (day: int64) = (HudLayout.calendarReading (day * Sim.ticksPerDay)).Season
+ let distinct = [ 0L .. 111L ] |> List.map seasonOfDay |> List.distinct
+ assertEqualMsg 4 distinct.Length "四个季节都应出现"
+ assertEqualMsg (seasonOfDay 0L) (seasonOfDay 27L) "同一季内 28 天不换季"
+ Assert.IsTrue(seasonOfDay 0L <> seasonOfDay 28L, "满 28 天必须换季")
+
+ [<TestMethod>]
+ member _.CalendarCardFitsTopRightWithoutOverlap () =
+ for tick in [ tickNoon; tickNight ] do
+ let reading = HudLayout.calendarReading tick
+ let rect = HudLayout.calendarCardRect 1280 reading
+ Assert.IsTrue(rect.X >= 0 && rect.Y >= 0, "卡片不得越出视口左上")
+ Assert.IsTrue(rect.X + rect.Width <= 1280, "卡片不得越出视口右边")
+ Assert.IsTrue(rect.Y >= HudLayout.barHeight + HudLayout.accentHeight, "卡片不得压住顶部窄条")
+ let line1 = HudLayout.calendarLine1 reading
+ let line2 = HudLayout.calendarLine2 reading
+ let textW =
+ max
+ (FloatingPrompt.measureWidth line1 HudLayout.textScale)
+ (FloatingPrompt.measureWidth line2 HudLayout.textScale)
+ Assert.IsTrue(HudLayout.iconWidth + HudLayout.iconGap + textW <= rect.Width, "卡片宽度须容下图标与两行文字")
+ Assert.IsTrue(2 * (8 * HudLayout.textScale) + HudLayout.calendarLineGap <= rect.Height, "卡片高度须容下两行文字")
+
+ // ---- 2. 体力图形化条形 ----
+
+ [<TestMethod>]
+ member _.EnergyBarIsProportionalAndClampedToRealState () =
+ closeTo 0.0f (HudLayout.energyBarFill 0.0f) "0 体力空条"
+ closeTo 1.0f (HudLayout.energyBarFill 100.0f) "满体力满条"
+ closeTo 0.5f (HudLayout.energyBarFill 50.0f) "50 体力半条"
+ closeTo 0.0f (HudLayout.energyBarFill -25.0f) "下限截断"
+ closeTo 1.0f (HudLayout.energyBarFill 250.0f) "上限截断"
+ let track = HudLayout.energyBarTrack 100 10 60
+ let fill = HudLayout.energyBarFillRect 100 10 60 world.Avatar.Mind.Needs.Energy
+ Assert.IsTrue(fill.X >= track.X, "填充不得越过轨道左沿")
+ Assert.IsTrue(fill.X + fill.Width <= track.X + track.Width, "填充不得越过轨道右沿")
+ Assert.IsTrue(fill.Height <= track.Height, "填充不得高过轨道")
+ assertEqualMsg 0 (HudLayout.energyBarFillRect 100 10 60 0.0f).Width "空体力宽度为 0"
+ assertEqualMsg track.Width (HudLayout.energyBarFillRect 100 10 60 100.0f).Width "满体力填满轨道"
+
+ // ---- 3. hotbar 布局与选中 ----
+
+ [<TestMethod>]
+ member _.HotbarSlotsAreTenDistinctNonOverlappingAndInsideViewport () =
+ assertEqualMsg 10 HudLayout.hotbarSlotCount "共 10 格"
+ let bar = HudLayout.hotbarBarRect 1280 720
+ Assert.IsTrue(bar.X >= 0 && bar.Y >= 0, "hotbar 不得越出视口")
+ Assert.IsTrue(bar.X + bar.Width <= 1280 && bar.Y + bar.Height <= 720, "hotbar 不得越出视口")
+ let slots = [ 0 .. HudLayout.hotbarSlotCount - 1 ] |> List.map (HudLayout.hotbarSlotRect 1280 720)
+ for slot in slots do
+ Assert.IsTrue(slot.X >= bar.X && slot.Y >= bar.Y, "槽位须在底栏内")
+ Assert.IsTrue(
+ slot.X + slot.Width <= bar.X + bar.Width && slot.Y + slot.Height <= bar.Y + bar.Height,
+ "槽位须在底栏内")
+ assertEqualMsg HudLayout.hotbarSlotSize slot.Width "槽宽"
+ assertEqualMsg HudLayout.hotbarSlotSize slot.Height "槽高"
+ slots
+ |> List.pairwise
+ |> List.iter (fun (a, b) -> Assert.IsTrue(b.X >= a.X + a.Width, "槽位不得重叠"))
+
+ [<TestMethod>]
+ member _.HotbarIconsAreTenOriginalWellFormedAndDistinct () =
+ let icons = [ 0 .. HudLayout.hotbarSlotCount - 1 ] |> List.map HudLayout.hotbarIconRows
+ for rows in icons do
+ assertEqualMsg HudLayout.iconGrid rows.Length "图标行数"
+ for row in rows do
+ assertEqualMsg HudLayout.iconGrid row.Length "图标行宽"
+ Assert.IsTrue(rows |> List.exists (fun row -> row.Contains "1"), "图标须有实心像素")
+ assertEqualMsg HudLayout.hotbarSlotCount (icons |> List.distinct |> List.length) "图标互异"
+ Assert.IsTrue(HudLayout.iconGrid * HudLayout.hotbarIconScale <= HudLayout.hotbarSlotSize, "图标须放进槽位")
+
+ [<TestMethod>]
+ member _.HotbarDigitKeysOneThroughZeroSelectSlots () : unit =
+ for digit in 1 .. 9 do
+ assertEqualMsg (Some (digit - 1)) (HudLayout.selectSlotForDigit digit) (sprintf "键 %d" digit)
+ assertEqualMsg (Some 9) (HudLayout.selectSlotForDigit 0) "键 0 -> 第 10 格"
+ assertEqualMsg None (HudLayout.selectSlotForDigit 11) "非 1-0 键不选格"
+
+ [<TestMethod>]
+ member _.HotbarSelectionCyclesWithinBounds () =
+ assertEqualMsg 0 (HudLayout.cycleHotbar 0 0) "原地"
+ assertEqualMsg 9 (HudLayout.cycleHotbar 0 -1) "首格向前到末格"
+ assertEqualMsg 0 (HudLayout.cycleHotbar 9 1) "末格向后到首格"
+ assertEqualMsg 3 (HudLayout.cycleHotbar 8 5) "跨回绕"
+ assertEqualMsg (HudLayout.hotbarSlotCount - 1) (HudLayout.cycleHotbar 0 (HudLayout.hotbarSlotCount - 1)) "最大步"
+ for start in 0 .. HudLayout.hotbarSlotCount - 1 do
+ for delta in -25 .. 25 do
+ let next = HudLayout.cycleHotbar start delta
+ Assert.IsTrue(next >= 0 && next < HudLayout.hotbarSlotCount, sprintf "循环越界 %d+%d" start delta)
+
+ // ---- 4. 菜单打开时 hotbar 输入隔离 ----
+
+ [<TestMethod>]
+ member _.HotbarInputIsIsolatedWhileMenuOrPanelIsOpen () =
+ Assert.IsTrue(HudLayout.hotbarInputAllowed true true, "游戏内世界输入可用时 hotbar 可响应")
+ Assert.IsFalse(HudLayout.hotbarInputAllowed false true, "Esc 菜单打开(非 Playing)时 hotbar 输入不穿透")
+ Assert.IsFalse(HudLayout.hotbarInputAllowed true false, "面板/对话打开时 hotbar 输入不穿透")
+ Assert.IsFalse(HudLayout.hotbarInputAllowed false false, "菜单+面板同时打开时更不穿透")