namespace LivingVillage.Desktop.Tests open System open Microsoft.VisualStudio.TestTools.UnitTesting open LivingVillage.Kernel open LivingVillage.Kernel.Sim open LivingVillage.Desktop /// P43 界面切片:右上日历钟卡、体力条形图、底部 10 格 hotbar 的纯模型与输入隔离回归。 [] 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. 日历钟卡 ---- [] 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 天必须换季") [] 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. 体力图形化条形 ---- [] 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 布局与选中 ---- [] 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, "槽位不得重叠")) [] 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, "图标须放进槽位") [] 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 键不选格" [] 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 输入隔离 ---- [] 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, "菜单+面板同时打开时更不穿透")