summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop.Tests
diff options
context:
space:
mode:
Diffstat (limited to 'src/LivingVillage.Desktop.Tests')
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P36PromptTests.fs235
2 files changed, 236 insertions, 0 deletions
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index d32a21f..20d9e61 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -16,6 +16,7 @@
<Compile Include="PerformanceSummaryTests.fs" />
<Compile Include="M3BatchOutputTests.fs" />
<Compile Include="AssetPackTests.fs" />
+ <Compile Include="P36PromptTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P36PromptTests.fs b/src/LivingVillage.Desktop.Tests/P36PromptTests.fs
new file mode 100644
index 0000000..bc398d3
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P36PromptTests.fs
@@ -0,0 +1,235 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+open LivingVillage.Desktop
+open LivingVillage.Desktop.VillagePresentation
+
+/// P36:情境提示系统契约回归。覆盖距离边界、目标切换、离开消失、隔墙不触发、
+/// 对话层输入隔离、提示对象与按键对象一致、布局不遮挡、Esc 关闭当前层、保存反馈。
+[<TestClass>]
+type P36PromptTests () =
+
+ let atPosition (x: float32) (y: float32) (world: World) : World =
+ { world with Avatar = { world.Avatar with Pos = ({ X = x; Y = y } : Vec2) } }
+
+ /// 把所有村民先移到远处,再按给定位置摆放,隔离出唯一候选目标。
+ let withNpcsAt (positions: (int * Vec2) list) (world: World) : World =
+ let npcs = Array.copy world.Npcs
+ for index in 0 .. npcs.Length - 1 do
+ npcs.[index] <- { npcs.[index] with Pos = ({ X = 4000.0f; Y = 4000.0f } : Vec2) }
+ for (index, position) in positions do
+ npcs.[index] <- { npcs.[index] with Pos = position }
+ { world with Npcs = npcs }
+
+ let actionText (action: InteractionAction) : string =
+ match action with
+ | TalkTo _ -> "E 交谈"
+ | EnterHome _ -> "E 进屋"
+ | ExitHome _ -> "E 出屋"
+
+ let doorCenter : Vec2 =
+ { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2)
+ Y = float32 (23 * Sim.tilePixels + Sim.tilePixels / 2) }
+
+ [<TestMethod>]
+ member _.PromptAppearsOnlyInsideInteractionRangeAtBoundary () =
+ let baseWorld = Sim.initialWorldN 42UL 4 |> withNpcsAt []
+ let avatar : Vec2 = { X = 1600.0f; Y = 800.0f }
+ let atBoundary =
+ baseWorld
+ |> atPosition avatar.X avatar.Y
+ |> withNpcsAt [ 0, { X = avatar.X + Sim.chatRangePx; Y = avatar.Y } ]
+ let beyond =
+ baseWorld
+ |> atPosition avatar.X avatar.Y
+ |> withNpcsAt [ 0, { X = avatar.X + Sim.chatRangePx + 0.5f; Y = avatar.Y } ]
+
+ Assert.AreEqual<InteractionAction option>(
+ Some(TalkTo(NpcId 0)),
+ InteractionResolver.resolve atBoundary Outside |> Option.map (fun target -> target.Action))
+ Assert.AreEqual<InteractionAction option>(
+ None,
+ InteractionResolver.resolve beyond Outside |> Option.map (fun target -> target.Action))
+
+ [<TestMethod>]
+ member _.PromptSwitchesToTheNearestReachableTargetDeterministically () =
+ let baseWorld = Sim.initialWorldN 42UL 4
+ let avatar : Vec2 = { X = 1600.0f; Y = 800.0f }
+ let competing =
+ baseWorld
+ |> atPosition avatar.X avatar.Y
+ |> withNpcsAt
+ [ 0, { X = avatar.X + 72.0f; Y = avatar.Y }
+ 1, { X = avatar.X + 48.0f; Y = avatar.Y } ]
+
+ Assert.AreEqual<InteractionAction option>(
+ Some(TalkTo(NpcId 1)),
+ InteractionResolver.resolve competing Outside |> Option.map (fun target -> target.Action))
+
+ // 走近后最近目标切换为 0 号,仍然一帧只选一个。
+ let switched = competing |> atPosition (avatar.X + 64.0f) avatar.Y
+ Assert.AreEqual<InteractionAction option>(
+ Some(TalkTo(NpcId 0)),
+ InteractionResolver.resolve switched Outside |> Option.map (fun target -> target.Action))
+
+ [<TestMethod>]
+ member _.PromptDisappearsImmediatelyAfterLeavingRange () =
+ let world =
+ Sim.initialWorldN 42UL 4
+ |> atPosition 1600.0f 800.0f
+ |> withNpcsAt [ 0, { X = 1632.0f; Y = 800.0f } ]
+ let view = M5Interaction.refreshPrompt world M5Interaction.initial
+ Assert.AreEqual<string option>(Some "E 交谈", view.Prompt)
+ Assert.IsTrue(view.PromptTargetPos.IsSome)
+
+ let left = world |> atPosition 2400.0f 1600.0f
+ let cleared = M5Interaction.refreshPrompt left view
+ Assert.AreEqual<string option>(None, cleared.Prompt)
+ Assert.AreEqual<Vec2 option>(None, cleared.PromptTargetPos)
+
+ [<TestMethod>]
+ member _.PromptNeverTriggersThroughAWall () =
+ // 民居西墙世界瓦片 (31,22);玩家在墙东侧紧邻,村民在墙后,水平距离在交谈范围内,
+ // 但视线被墙体挡住 -> 不得触发。
+ let wallAvatar : Vec2 =
+ { X = float32 (32 * Sim.tilePixels + Sim.tilePixels / 2)
+ Y = float32 (22 * Sim.tilePixels + Sim.tilePixels / 2) }
+ let wallNpc : Vec2 =
+ { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2)
+ Y = float32 (22 * Sim.tilePixels + Sim.tilePixels / 2) }
+ let world =
+ Sim.initialWorldN 42UL 1
+ |> atPosition wallAvatar.X wallAvatar.Y
+ |> withNpcsAt [ 0, wallNpc ]
+
+ Assert.IsTrue(InteractionResolver.hasClearPath wallAvatar wallNpc |> not)
+ Assert.AreEqual<InteractionAction option>(
+ None,
+ InteractionResolver.resolve world Outside |> Option.map (fun target -> target.Action))
+ Assert.AreEqual<string option>(None, (M5Interaction.refreshPrompt world M5Interaction.initial).Prompt)
+
+ [<TestMethod>]
+ member _.OpenDialogueLayerIsolatesWorldInteractionCommands () =
+ let world =
+ Sim.initialWorldN 42UL 4
+ |> atPosition 1600.0f 800.0f
+ |> withNpcsAt [ 0, { X = 1632.0f; Y = 800.0f } ]
+ let dialogueView =
+ { M5Interaction.initial with
+ Panel = DialoguePanel
+ Menu = Some { Target = NpcId 0; Options = Sim.dialogueOptions } }
+
+ Assert.IsFalse(M5Interaction.worldInputAllowed dialogueView)
+
+ // E(Interact)在对白层打开时不得穿透重开/改选目标。
+ let interactWorld, interactView = M5Interaction.apply Interact world dialogueView
+ Assert.AreEqual<World>(world, interactWorld)
+ Assert.AreEqual<M5Panel>(DialoguePanel, interactView.Panel)
+ Assert.IsTrue(interactView.Menu.IsSome)
+
+ // Q(Observe)同样被隔离。
+ let observeWorld, observeView = M5Interaction.apply Observe world dialogueView
+ Assert.AreEqual<World>(world, observeWorld)
+ Assert.AreEqual<M5Panel>(DialoguePanel, observeView.Panel)
+ Assert.AreEqual<Observation list>([], observeView.Observations)
+
+ [<TestMethod>]
+ member _.PromptObjectAlwaysMatchesTheInteractTarget () =
+ // 村民:提示文本与 resolve 的动作一致,按 E 后菜单目标是同一村民。
+ let npcWorld =
+ Sim.initialWorldN 42UL 4
+ |> atPosition 1600.0f 800.0f
+ |> withNpcsAt [ 0, { X = 1632.0f; Y = 800.0f } ]
+ let npcTarget = InteractionResolver.resolve npcWorld Outside
+ Assert.IsTrue(npcTarget.IsSome)
+ Assert.AreEqual<string>(actionText npcTarget.Value.Action, npcTarget.Value.Text)
+ let npcView = M5Interaction.refreshPrompt npcWorld M5Interaction.initial
+ Assert.AreEqual<string option>(Some npcTarget.Value.Text, npcView.Prompt)
+ let _, npcInteracted = M5Interaction.apply Interact npcWorld M5Interaction.initial
+ Assert.AreEqual<M5Panel>(DialoguePanel, npcInteracted.Panel)
+ Assert.AreEqual<NpcId>(NpcId 0, npcInteracted.Menu.Value.Target)
+
+ // 民居门:提示 "E 进屋",按 E 后进入同一民居。
+ let doorWorld =
+ Sim.initialWorldN 42UL 4
+ |> withNpcsAt []
+ |> atPosition doorCenter.X doorCenter.Y
+ let doorTarget = InteractionResolver.resolve doorWorld Outside
+ Assert.IsTrue(doorTarget.IsSome)
+ Assert.AreEqual<string>("E 进屋", doorTarget.Value.Text)
+ Assert.AreEqual<string>(actionText doorTarget.Value.Action, doorTarget.Value.Text)
+ let _, doorInteracted = M5Interaction.apply Interact doorWorld M5Interaction.initial
+ Assert.AreEqual<HomeMode>(Inside(HomeId 1), doorInteracted.HomeMode)
+
+ [<TestMethod>]
+ member _.EscapeClosesTheCurrentLayerBackToWorld () =
+ let world = Sim.initialWorldN 42UL 0
+ for panel in [ DialoguePanel; NeedsPanel; ObservationPanel; ChroniclePanel; TaskPanel ] do
+ let view = { M5Interaction.initial with Panel = panel }
+ let _, closed = M5Interaction.apply ClosePanel world view
+ Assert.IsTrue(closed.Panel = WorldPanel, sprintf "Esc 应关闭面板 %A" panel)
+ Assert.IsTrue(closed.Menu.IsNone, sprintf "关闭 %A 后应清空菜单" panel)
+
+ [<TestMethod>]
+ member _.PauseHelpAndControlsPageReturnToThePreviousLayerOnEscape () =
+ let control = SimulationControl.initial
+ let playing = MenuState.create control true |> MenuState.enterGame
+ let helpState, paused = MenuState.enterPauseHelp control playing
+ Assert.AreEqual<MenuPage>(Controls, helpState.Page)
+ Assert.IsTrue(paused.Paused)
+ Assert.AreEqual<MenuPage>(Playing, helpState.ReturnPage)
+
+ let returnedState, restored = MenuState.exitPauseHelp control helpState
+ Assert.AreEqual<MenuPage>(Playing, returnedState.Page)
+ Assert.IsFalse(restored.Paused)
+
+ let backState, _ = MenuState.update Back helpState
+ Assert.AreEqual<MenuPage>(Playing, backState.Page)
+
+ [<TestMethod>]
+ member _.SaveFeedbackUsesATransientChineseToast () =
+ let success =
+ { M5Interaction.initial with
+ Status = "save succeeded"
+ StatusTick = Some 100L }
+ Assert.AreEqual<string>("保存成功", M5Interaction.statusText success)
+ Assert.AreEqual<string option>(Some "保存成功", M5Interaction.transientStatusLine 180L success)
+ Assert.AreEqual<string option>(
+ None,
+ M5Interaction.transientStatusLine (100L + M5Interaction.statusDisplayDurationTicks) success)
+
+ let failed =
+ { M5Interaction.initial with
+ Status = "save failed"
+ StatusTick = Some 100L }
+ Assert.AreEqual<string>("保存失败", M5Interaction.statusText failed)
+ Assert.AreEqual<string option>(Some "保存失败", M5Interaction.transientStatusLine 120L failed)
+
+ [<TestMethod>]
+ member _.PromptLayoutFitsSmallAndLargeWindowsWithoutCoveringHud () =
+ for (width, height) in [ (800, 600); (1024, 576); (1280, 720); (1920, 1080) ] do
+ let anchors : (int * int) list =
+ [ (16, 16)
+ (width / 2, height / 2)
+ (width - 40, FloatingPrompt.hudTopBarHeight + 4)
+ (24, height - FloatingPrompt.hudBottomBandHeight - 4)
+ (width - 24, height - 24) ]
+ for (anchorX, anchorY) in anchors do
+ for text in [ "E 交谈"; "E 进屋"; "E 出屋" ] do
+ let rect =
+ FloatingPrompt.layout width height (float32 anchorX) (float32 anchorY) 0.0f 0.0f 2 text
+ Assert.IsTrue(rect.Width > 0, sprintf "提示宽度被截为 0 (%dx%d)" width height)
+ Assert.IsTrue(
+ rect.X >= 0 && rect.X + rect.Width <= width,
+ sprintf "水平越界 %dx%d anchor=(%d,%d) %A" width height anchorX anchorY rect)
+ Assert.IsTrue(
+ rect.Y >= 0 && rect.Y + rect.Height <= height,
+ sprintf "垂直越界 %dx%d anchor=(%d,%d) %A" width height anchorX anchorY rect)
+ Assert.IsTrue(
+ rect.Y >= FloatingPrompt.hudTopBarHeight,
+ sprintf "遮挡顶部状态条 %dx%d" width height)
+ Assert.IsTrue(
+ rect.Y + rect.Height <= height - FloatingPrompt.hudBottomBandHeight + 8,
+ sprintf "遮挡底部 HUD 带 %dx%d" width height)